diff --git a/api.js b/api.js index 944e3e0de..f31d2d0c6 100644 --- a/api.js +++ b/api.js @@ -13,6 +13,7 @@ var commonPathPrefix = require('common-path-prefix'); var resolveCwd = require('resolve-cwd'); var uniqueTempDir = require('unique-temp-dir'); var findCacheDir = require('find-cache-dir'); +var slash = require('slash'); var AvaError = require('./lib/ava-error'); var fork = require('./lib/fork'); var formatter = require('./lib/enhance-assert').formatter(); @@ -64,6 +65,7 @@ Api.prototype._runFile = function (file) { }); return fork(file, options) + .on('teardown', this._handleTeardown) .on('stats', this._handleStats) .on('test', this._handleTest) .on('unhandledRejections', this._handleRejections) @@ -96,6 +98,10 @@ Api.prototype._handleExceptions = function (data) { this.errors.push(err); }; +Api.prototype._handleTeardown = function (data) { + this.emit('dependencies', data.file, data.dependencies); +}; + Api.prototype._handleStats = function (stats) { this.testCount += stats.testCount; }; @@ -242,10 +248,17 @@ function handlePaths(files, excludePatterns) { return files .map(function (file) { if (fs.statSync(file).isDirectory()) { - return handlePaths([path.join(file, '**', '*.js')], excludePatterns); + var pattern = path.join(file, '**', '*.js'); + if (process.platform === 'win32') { + // Always use / in patterns, harmonizing matching across platforms. + pattern = slash(pattern); + } + return handlePaths([pattern], excludePatterns); } - return file; + // globby returns slashes even on Windows. Normalize here so the file + // paths are consistently platform-accurate as tests are run. + return path.normalize(file); }) .then(flatten) .filter(function (file) { diff --git a/cli.js b/cli.js index 7164983fc..3e100f524 100755 --- a/cli.js +++ b/cli.js @@ -29,7 +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 Watcher = require('./lib/watcher'); var Api = require('./api'); // Bluebird specific @@ -133,7 +133,8 @@ if (files.length === 0) { if (cli.flags.watch) { try { - watcher.start(logger, api, files, arrify(cli.flags.source), process.stdin); + var watcher = new Watcher(logger, api, files, arrify(cli.flags.source)); + watcher.observeStdin(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/test-worker.js b/lib/test-worker.js index f241042e8..b0ff31c66 100644 --- a/lib/test-worker.js +++ b/lib/test-worker.js @@ -89,6 +89,17 @@ module.constructor._nodeModulePaths = function () { return ret; }; +var dependencies = []; +Object.keys(require.extensions).forEach(function (ext) { + var wrappedHandler = require.extensions[ext]; + require.extensions[ext] = function (module, filename) { + if (filename !== testPath) { + dependencies.push(filename); + } + wrappedHandler(module, filename); + }; +}); + require(testPath); process.on('uncaughtException', function (exception) { @@ -122,11 +133,19 @@ process.on('ava-exit', function () { }, delay); }); +var tearingDown = false; process.on('ava-teardown', function () { + // ava-teardown can be sent more than once. + if (tearingDown) { + return; + } + tearingDown = true; + var rejections = loudRejection.currentlyUnhandled(); if (rejections.length === 0) { - return exit(); + exit(); + return; } rejections = rejections.map(function (rejection) { @@ -142,5 +161,8 @@ process.on('ava-teardown', function () { }); function exit() { - send('teardown'); + // Include dependencies in the final teardown message. This ensures the full + // set of dependencies is included no matter how the process exits, unless + // it flat out crashes. + send('teardown', {dependencies: dependencies}); } diff --git a/lib/watcher.js b/lib/watcher.js index 064648497..244255e32 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -2,10 +2,14 @@ var AvaError = require('./ava-error'); var debug = require('debug')('ava:watcher'); +var diff = require('arr-diff'); +var flatten = require('arr-flatten'); +var union = require('array-union'); +var uniq = require('array-uniq'); var defaultIgnore = require('ignore-by-default').directories(); var multimatch = require('multimatch'); var nodePath = require('path'); -var Promise = require('bluebird'); +var slash = require('slash'); function requireChokidar() { try { @@ -23,89 +27,92 @@ function rethrowAsync(err) { }); } -function getChokidarPatterns(sources, initialFiles) { - var paths = []; - var ignored = []; +// Used on paths before they're passed to multimatch to Harmonize matching +// across platforms. +var matchable = process.platform === 'win32' ? slash : function (path) { + return path; +}; - sources.forEach(function (pattern) { - if (pattern[0] === '!') { - ignored.push(pattern.slice(1)); - } else { - paths.push(pattern); - } - }); +function Watcher(logger, api, files, sources) { + this.debouncer = new Debouncer(this); - if (paths.length === 0) { - paths = ['package.json', '**/*.js']; - } - paths = paths.concat(initialFiles); + this.isTest = makeTestMatcher(files, api.excludePatterns); + this.run = function (specificFiles) { + logger.reset(); + this.busy = api.run(specificFiles || files).then(function () { + logger.finish(); + }, rethrowAsync); + }; - if (ignored.length === 0) { - ignored = defaultIgnore; - } + this.testDependencies = []; + this.trackTestDependencies(api, sources); - return {paths: paths, ignored: ignored}; + this.dirtyStates = {}; + this.watchFiles(files, sources); + this.rerunAll(); } -exports.start = function (logger, api, files, sources, stdin) { - var isTest = makeTestMatcher(files, api.excludePatterns); - var patterns = getChokidarPatterns(sources, files); +module.exports = Watcher; + +Watcher.prototype.watchFiles = function (files, sources) { + var patterns = getChokidarPatterns(files, sources); - var watcher = requireChokidar().watch(patterns.paths, { + var self = this; + requireChokidar().watch(patterns.paths, { ignored: patterns.ignored, ignoreInitial: true - }); - - var busy = api.run(files).then(function () { - logger.finish(); - }).catch(rethrowAsync); - - var dirtyStates = {}; - watcher.on('all', function (event, path) { + }).on('all', function (event, path) { if (event === 'add' || event === 'change' || event === 'unlink') { debug('Detected %s of %s', event, path); - dirtyStates[path] = event; - debounce(); + self.dirtyStates[path] = event; + self.debouncer.debounce(); } }); +}; - var debouncing = null; - var debounceAgain = false; - function debounce() { - if (debouncing) { - debounceAgain = true; - return; - } +Watcher.prototype.trackTestDependencies = function (api, sources) { + var isSource = makeSourceMatcher(sources); + + var cwd = process.cwd(); + var relative = function (absPath) { + return nodePath.relative(cwd, absPath); + }; - 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, files, isTest, dirtyStates); - dirtyStates = {}; - debouncing = null; - debounceAgain = false; - } - }); - }, 10); + var self = this; + api.on('dependencies', function (file, dependencies) { + var sourceDeps = dependencies.map(relative).filter(isSource); + self.updateTestDependencies(file, sourceDeps); + }); +}; + +Watcher.prototype.removeUnlinkedTestDependencies = function (unlinkedTests) { + unlinkedTests.forEach(function (testFile) { + this.updateTestDependencies(testFile, []); + }, this); +}; + +Watcher.prototype.updateTestDependencies = function (file, sources) { + if (sources.length === 0) { + this.testDependencies = this.testDependencies.filter(function (dep) { + return dep.file !== file; + }); + return; } - function cancelDebounce() { - if (debouncing) { - clearTimeout(debouncing); - debouncing = null; - debounceAgain = false; + var isUpdate = this.testDependencies.some(function (dep) { + if (dep.file === file) { + dep.sources = sources; + return true; } + }); + + if (!isUpdate) { + this.testDependencies.push(new TestDependency(file, sources)); } +}; + +Watcher.prototype.observeStdin = function (stdin) { + var self = this; stdin.resume(); stdin.setEncoding('utf8'); @@ -117,16 +124,167 @@ exports.start = function (logger, api, files, sources, stdin) { // Cancel the debouncer, it might rerun specific tests whereas *all* tests // need to be rerun. - cancelDebounce(); - busy.then(function () { + self.debouncer.cancel(); + self.busy.then(function () { // Cancel the debouncer again, it might have restarted while waiting for // the busy promise to fulfil. - cancelDebounce(); - busy = runAfterChanges(logger, api, files, isTest, {}); + self.debouncer.cancel(); + self.rerunAll(); }); }); }; +Watcher.prototype.rerunAll = function () { + this.dirtyStates = {}; + this.run(); +}; + +Watcher.prototype.runAfterChanges = function () { + var dirtyStates = this.dirtyStates; + this.dirtyStates = {}; + + var dirtyPaths = Object.keys(dirtyStates); + var dirtyTests = dirtyPaths.filter(this.isTest); + var dirtySources = diff(dirtyPaths, dirtyTests); + var addedOrChangedTests = dirtyTests.filter(function (path) { + return dirtyStates[path] !== 'unlink'; + }); + var unlinkedTests = diff(dirtyTests, addedOrChangedTests); + + this.removeUnlinkedTestDependencies(unlinkedTests); + // No need to rerun tests if the only change is that tests were deleted. + if (unlinkedTests.length === dirtyPaths.length) { + return; + } + + if (dirtySources.length === 0) { + // Run any new or changed tests. + this.run(addedOrChangedTests); + return; + } + + // Try to find tests that depend on the changed source files. + var testsBySource = dirtySources.map(function (path) { + return this.testDependencies.filter(function (dep) { + return dep.contains(path); + }).map(function (dep) { + debug('%s is a dependency of %s', path, dep.file); + return dep.file; + }); + }, this).filter(function (tests) { + return tests.length > 0; + }); + + // Rerun all tests if source files were changed that could not be traced to + // specific tests. + if (testsBySource.length !== dirtySources.length) { + debug('Sources remain that cannot be traced to specific tests. Rerunning all tests'); + this.run(); + return; + } + + // Run all affected tests. + this.run(union(addedOrChangedTests, uniq(flatten(testsBySource)))); +}; + +function Debouncer(watcher) { + this.watcher = watcher; + + this.timer = null; + this.repeat = false; +} + +Debouncer.prototype.debounce = function () { + if (this.timer) { + this.again = true; + return; + } + + var self = this; + var timer = this.timer = setTimeout(function () { + self.watcher.busy.then(function () { + // Do nothing if debouncing was canceled while waiting for the busy + // promise to fulfil. + if (self.timer !== timer) { + return; + } + + if (self.again) { + self.timer = null; + self.again = false; + self.debounce(); + } else { + self.watcher.runAfterChanges(); + self.timer = null; + self.again = false; + } + }); + }, 10); +}; + +Debouncer.prototype.cancel = function () { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + this.again = false; + } +}; + +function getChokidarPatterns(files, sources) { + 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(files); + + if (ignored.length === 0) { + ignored = defaultIgnore; + } + + return {paths: paths, ignored: ignored}; +} + +function makeSourceMatcher(sources) { + var patterns = sources; + + var hasPositivePattern = patterns.some(function (pattern) { + return pattern[0] !== '!'; + }); + var hasNegativePattern = patterns.some(function (pattern) { + return pattern[0] === '!'; + }); + + // Same defaults as used for Chokidar. + if (!hasPositivePattern) { + patterns = ['package.json', '**/*.js'].concat(patterns); + } + if (!hasNegativePattern) { + patterns = patterns.concat(defaultIgnore.map(function (dir) { + return '!' + dir + '/**/*'; + })); + } + + return function (path) { + // Ignore paths outside the current working directory. They can't be matched + // to a pattern. + if (/^\.\./.test(path)) { + return false; + } + + return multimatch(matchable(path), patterns).length === 1; + }; +} + function makeTestMatcher(files, excludePatterns) { var initialPatterns = files.concat(excludePatterns); return function (path) { @@ -136,7 +294,7 @@ function makeTestMatcher(files, excludePatterns) { } // Check if the entire path matches a pattern. - if (multimatch(path, initialPatterns).length === 1) { + if (multimatch(matchable(path), initialPatterns).length === 1) { return true; } @@ -151,7 +309,8 @@ function makeTestMatcher(files, excludePatterns) { var subpaths = dirname.split(nodePath.sep).reduce(function (subpaths, component) { var parent = subpaths[subpaths.length - 1]; if (parent) { - subpaths.push(nodePath.join(parent, component)); + // Always use / to makes multimatch consistent across platforms. + subpaths.push(parent + '/' + component); } else { subpaths.push(component); } @@ -163,41 +322,21 @@ function makeTestMatcher(files, excludePatterns) { var recursivePatterns = subpaths.filter(function (subpath) { return multimatch(subpath, initialPatterns).length === 1; }).map(function (subpath) { - return nodePath.join(subpath, '**', '*.js'); + // Always use / to makes multimatch consistent across platforms. + return 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; + return multimatch(matchable(path), recursivePatterns.concat(excludePatterns)).length === 1; }; } -function runAfterChanges(logger, api, files, 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(files)); - } - }).then(function () { - logger.finish(); - }).catch(rethrowAsync); +function TestDependency(file, sources) { + this.file = file; + this.sources = sources; } + +TestDependency.prototype.contains = function (source) { + return this.sources.indexOf(source) !== -1; +}; diff --git a/package.json b/package.json index 2eea16a9a..66a717f5a 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,10 @@ "tap" ], "dependencies": { + "arr-diff": "^2.0.0", "arr-flatten": "^1.0.1", + "array-union": "^1.0.1", + "array-uniq": "^1.0.2", "arrify": "^1.0.0", "ava-init": "^0.1.0", "babel-core": "^6.3.21", @@ -118,6 +121,7 @@ "resolve-cwd": "^1.0.0", "serialize-error": "^1.1.0", "set-immediate-shim": "^1.0.1", + "slash": "^1.0.0", "source-map-support": "^0.4.0", "stack-utils": "^0.4.0", "strip-bom": "^2.0.0", diff --git a/test/api.js b/test/api.js index 453f14105..1fe220431 100644 --- a/test/api.js +++ b/test/api.js @@ -612,3 +612,35 @@ test('resets state before running', function (t) { t.is(api.passCount, 1); }); }); + +test('emits dependencies for test files', function (t) { + t.plan(8); + + var api = new Api({ + require: [path.resolve('test/fixture/with-dependencies/require-custom.js')] + }); + + var testFiles = [ + path.normalize('test/fixture/with-dependencies/no-tests.js'), + path.normalize('test/fixture/with-dependencies/test.js'), + path.normalize('test/fixture/with-dependencies/test-failure.js'), + path.normalize('test/fixture/with-dependencies/test-uncaught-exception.js') + ]; + + var sourceFiles = [ + path.resolve('test/fixture/with-dependencies/dep-1.js'), + path.resolve('test/fixture/with-dependencies/dep-2.js'), + path.resolve('test/fixture/with-dependencies/dep-3.custom') + ]; + + api.on('dependencies', function (file, dependencies) { + t.notEqual(testFiles.indexOf(file), -1); + t.same(dependencies.slice(-3), sourceFiles); + }); + + var result = api.run(['test/fixture/with-dependencies/*test*.js']); + + // The test files are designed to cause errors so ignore them here. + api.on('error', function () {}); + result.catch(function () {}); +}); diff --git a/test/fixture/with-dependencies/dep-1.js b/test/fixture/with-dependencies/dep-1.js new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixture/with-dependencies/dep-2.js b/test/fixture/with-dependencies/dep-2.js new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixture/with-dependencies/dep-3.custom b/test/fixture/with-dependencies/dep-3.custom new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixture/with-dependencies/no-tests.js b/test/fixture/with-dependencies/no-tests.js new file mode 100644 index 000000000..e82eb5a65 --- /dev/null +++ b/test/fixture/with-dependencies/no-tests.js @@ -0,0 +1,3 @@ +import './dep-1'; +import './dep-2'; +import './dep-3.custom'; diff --git a/test/fixture/with-dependencies/require-custom.js b/test/fixture/with-dependencies/require-custom.js new file mode 100644 index 000000000..052ac2550 --- /dev/null +++ b/test/fixture/with-dependencies/require-custom.js @@ -0,0 +1 @@ +require.extensions['.custom'] = require.extensions['.js'] diff --git a/test/fixture/with-dependencies/test-failure.js b/test/fixture/with-dependencies/test-failure.js new file mode 100644 index 000000000..9a4cff461 --- /dev/null +++ b/test/fixture/with-dependencies/test-failure.js @@ -0,0 +1,9 @@ +import test from '../../../'; + +import './dep-1'; +import './dep-2'; +import './dep-3.custom'; + +test('hey ho', t => { + t.fail(); +}); diff --git a/test/fixture/with-dependencies/test-uncaught-exception.js b/test/fixture/with-dependencies/test-uncaught-exception.js new file mode 100644 index 000000000..42a05881c --- /dev/null +++ b/test/fixture/with-dependencies/test-uncaught-exception.js @@ -0,0 +1,13 @@ +import test from '../../../'; + +import './dep-1'; +import './dep-2'; +import './dep-3.custom'; + +test('hey ho', t => { + t.pass(); +}); + +setImmediate(() => { + throw new Error('oops'); +}); diff --git a/test/fixture/with-dependencies/test.js b/test/fixture/with-dependencies/test.js new file mode 100644 index 000000000..1fdc1434b --- /dev/null +++ b/test/fixture/with-dependencies/test.js @@ -0,0 +1,9 @@ +import test from '../../../'; + +import './dep-1'; +import './dep-2'; +import './dep-3.custom'; + +test('hey ho', t => { + t.pass(); +}); diff --git a/test/watcher.js b/test/watcher.js index a14c9616f..e2a62a09f 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -12,22 +12,46 @@ var test = require('tap').test; var setImmediate = require('../lib/globals').setImmediate; +// Helper to make using beforeEach less arduous. +function makeGroup(test) { + return function group(desc, fn) { + test(desc, function (t) { + var beforeEach = function (fn) { + t.beforeEach(function (done) { + fn(); + done(); + }); + }; + + var pending = []; + var test = function (name, fn) { + pending.push(t.test(name, fn)); + }; + + fn(beforeEach, test, makeGroup(test)); + + return Promise.all(pending); + }); + }; +} +var group = makeGroup(test); + test('chokidar is not installed', function (t) { t.plan(2); - var subject = proxyquire.noCallThru().load('../lib/watcher', { + var Subject = proxyquire.noCallThru().load('../lib/watcher', { chokidar: null }); try { - subject.start({}, {excludePatterns: []}, [], []); + new Subject({}, {excludePatterns: [], on: function () {}}, [], []); // eslint-disable-line } 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) { +group('chokidar is installed', function (beforeEach, test, group) { var chokidar = { watch: sinon.stub() }; @@ -40,15 +64,16 @@ test('chokidar is installed', function (_t) { }; var api = { - run: sinon.stub(), excludePatterns: [ '!**/node_modules/**', '!**/fixtures/**', '!**/helpers/**' - ] + ], + on: function () {}, + run: sinon.stub() }; - var subject = proxyquire.noCallThru().load('../lib/watcher', { + var Subject = proxyquire.noCallThru().load('../lib/watcher', { chokidar: chokidar, debug: function (name) { return function () { @@ -60,18 +85,18 @@ test('chokidar is installed', function (_t) { }); var clock; - var emitter; + var chokidarEmitter; var stdin; var files; - _t.beforeEach(function (done) { + beforeEach(function () { if (clock) { clock.uninstall(); } clock = lolex.install(0, ['setImmediate', 'setTimeout', 'clearTimeout']); - emitter = new EventEmitter(); + chokidarEmitter = new EventEmitter(); chokidar.watch.reset(); - chokidar.watch.returns(emitter); + chokidar.watch.returns(chokidarEmitter); debug.reset(); @@ -88,22 +113,24 @@ test('chokidar is installed', function (_t) { stdin = new PassThrough(); stdin.pause(); - - done(); }); var start = function (sources) { - subject.start(logger, api, files, sources || [], stdin); + return new Subject(logger, api, files, sources || []); + }; + + var emitChokidar = function (event, path) { + chokidarEmitter.emit('all', event, path); }; var add = function (path) { - emitter.emit('all', 'add', path || 'source.js'); + emitChokidar('add', path || 'source.js'); }; var change = function (path) { - emitter.emit('all', 'change', path || 'source.js'); + emitChokidar('change', path || 'source.js'); }; var unlink = function (path) { - emitter.emit('all', 'unlink', path || 'source.js'); + emitChokidar('unlink', path || 'source.js'); }; var delay = function () { @@ -124,11 +151,6 @@ test('chokidar is installed', function (_t) { }); }; - 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(); @@ -221,14 +243,12 @@ test('chokidar is installed', function (_t) { done = resolve; })); - // reset isn't called in the initial run. - t.ok(logger.reset.notCalled); - variant.fire(); return debounce().then(function () { + t.ok(logger.reset.calledTwice); t.ok(api.run.calledTwice); // reset is called before the second run. - t.ok(logger.reset.calledBefore(api.run.secondCall)); + t.ok(logger.reset.secondCall.calledBefore(api.run.secondCall)); // no explicit files are provided. t.same(api.run.secondCall.args, [files]); @@ -334,14 +354,12 @@ test('chokidar is installed', function (_t) { 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(logger.reset.calledTwice); t.ok(api.run.calledTwice); // reset is called before the second run. - t.ok(logger.reset.calledBefore(api.run.secondCall)); + t.ok(logger.reset.secondCall.calledBefore(api.run.secondCall)); // the test.js file is provided t.same(api.run.secondCall.args, [['test.js']]); @@ -390,7 +408,7 @@ test('chokidar is installed', function (_t) { unlink('test.js'); return debounce().then(function () { - t.ok(logger.reset.notCalled); + t.ok(logger.reset.calledOnce); t.ok(api.run.calledOnce); }); }); @@ -493,7 +511,7 @@ test('chokidar is installed', function (_t) { test('reruns initial tests when "rs" is entered on stdin', function (t) { t.plan(2); api.run.returns(Promise.resolve()); - start(); + start().observeStdin(stdin); stdin.write('rs\n'); return delay().then(function () { @@ -509,7 +527,7 @@ test('chokidar is installed', function (_t) { test('entering "rs" on stdin cancels any debouncing', function (t) { t.plan(7); api.run.returns(Promise.resolve()); - start(); + start().observeStdin(stdin); var before = clock.now; var done; @@ -581,11 +599,11 @@ test('chokidar is installed', function (_t) { test('does nothing if anything other than "rs" is entered on stdin', function (t) { t.plan(2); api.run.returns(Promise.resolve()); - start(); + start().observeStdin(stdin); stdin.write('foo\n'); return debounce().then(function () { - t.ok(logger.reset.notCalled); + t.ok(logger.reset.calledOnce); t.ok(api.run.calledOnce); }); }); @@ -595,9 +613,9 @@ test('chokidar is installed', function (_t) { api.run.returns(Promise.resolve()); start(); - emitter.emit('all', 'foo'); + emitChokidar('foo'); return debounce().then(function () { - t.ok(logger.reset.notCalled); + t.ok(logger.reset.calledOnce); t.ok(api.run.calledOnce); }); }); @@ -641,5 +659,227 @@ test('chokidar is installed', function (_t) { }); }); - return Promise.all(pending); + group('tracks test dependencies', function (beforeEach, test) { + var apiEmitter; + beforeEach(function () { + apiEmitter = new EventEmitter(); + api.on = function (event, fn) { + apiEmitter.on(event, fn); + }; + }); + + var emitDependencies = function (file, dependencies) { + apiEmitter.emit('dependencies', file, dependencies); + }; + + var seed = function (sources) { + var done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + + var watcher = start(sources); + emitDependencies(path.join('test', '1.js'), [path.resolve('dep-1.js'), path.resolve('dep-3.js')]); + emitDependencies(path.join('test', '2.js'), [path.resolve('dep-2.js'), path.resolve('dep-3.js')]); + + done(); + api.run.returns(new Promise(function () {})); + return watcher; + }; + + test('runs specific tests that depend on changed sources', function (t) { + t.plan(2); + seed(); + + change('dep-1.js'); + return debounce().then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); + }); + }); + + test('reruns all tests if a source cannot be mapped to a particular test', function (t) { + t.plan(2); + seed(); + + change('cannot-be-mapped.js'); + return debounce().then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [files]); + }); + }); + + test('runs changed tests and tests that depend on changed sources', function (t) { + t.plan(2); + seed(); + + change('dep-1.js'); + change(path.join('test', '2.js')); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[path.join('test', '2.js'), path.join('test', '1.js')]]); + }); + }); + + test('avoids duplication when both a test and a source dependency change', function (t) { + t.plan(2); + seed(); + + change(path.join('test', '1.js')); + change('dep-1.js'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); + }); + }); + + test('stops tracking unlinked tests', function (t) { + t.plan(2); + seed(); + + unlink(path.join('test', '1.js')); + change('dep-3.js'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[path.join('test', '2.js')]]); + }); + }); + + test('updates test dependencies', function (t) { + t.plan(2); + seed(); + + emitDependencies(path.join('test', '1.js'), [path.resolve('dep-4.js')]); + change('dep-4.js'); + return debounce().then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); + }); + }); + + [ + { + desc: 'only tracks source dependencies', + sources: ['dep-1.js'] + }, + { + desc: 'exclusion patterns affect tracked source dependencies', + sources: ['!dep-2.js'] + } + ].forEach(function (variant) { + test(variant.desc, function (t) { + t.plan(2); + seed(variant.sources); + + // dep-2.js isn't treated as a source and therefore it's not tracked as + // a dependency for test/2.js. Pretend Chokidar detected a change to + // verify (normally Chokidar would also be ignoring this file but hey). + change('dep-2.js'); + return debounce().then(function () { + t.ok(api.run.calledTwice); + // Expect all tests to be rerun since dep-2.js is not a tracked + // dependency. + t.same(api.run.secondCall.args, [files]); + }); + }); + }); + + test('uses default patterns', function (t) { + t.plan(4); + seed(); + + emitDependencies(path.join('test', '1.js'), [path.resolve('package.json'), path.resolve('index.js'), path.resolve('lib/util.js')]); + emitDependencies(path.join('test', '2.js'), [path.resolve('foo.bar')]); + change('package.json'); + change('index.js'); + change(path.join('lib', 'util.js')); + + api.run.returns(Promise.resolve()); + return debounce(3).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); + + change('foo.bar'); + return debounce(); + }).then(function () { + t.ok(api.run.calledThrice); + // Expect all tests to be rerun since foo.bar is not a tracked + // dependency. + t.same(api.run.thirdCall.args, [files]); + }); + }); + + test('uses default exclusion patterns if no exclusion pattern is given', function (t) { + t.plan(2); + + // Ensure each directory is treated as containing sources, but rely on + // the default exclusion patterns, also based on these directories, to + // exclude them again. + var sources = defaultIgnore.map(function (dir) { + return dir + '/**/*'; + }); + seed(sources); + + // Synthesize an excluded file for each directory that's ignored by + // default. Apply deeper nesting for each file. + var excludedFiles = defaultIgnore.map(function (dir, index) { + var relPath = dir; + for (var i = index; i >= 0; i--) { + relPath = path.join(relPath, String(i)); + } + return relPath; + }); + + // Ensure test/1.js also depends on the excluded files. + emitDependencies(path.join('test', '1.js'), excludedFiles.map(function (relPath) { + return path.resolve(relPath); + }).concat('dep-1.js')); + + // Modify all excluded files. + excludedFiles.forEach(change); + + return debounce(excludedFiles.length).then(function () { + t.ok(api.run.calledTwice); + // Since the excluded files are not tracked as a dependency, all tests + // are expected to be rerun. + t.same(api.run.secondCall.args, [files]); + }); + }); + + test('ignores dependencies outside of the current working directory', function (t) { + t.plan(2); + seed(); + + emitDependencies(path.join('test', '1.js'), [path.resolve('../outside.js')]); + // Pretend Chokidar detected a change to verify (normally Chokidar would + // also be ignoring this file but hey). + change(path.join('..', 'outside.js')); + return debounce().then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [files]); + }); + }); + + test('logs a debug message when a dependent test is found', function (t) { + t.plan(2); + seed(); + + change('dep-1.js'); + return debounce().then(function () { + t.ok(debug.calledTwice); + t.same(debug.secondCall.args, ['ava:watcher', '%s is a dependency of %s', 'dep-1.js', path.join('test', '1.js')]); + }); + }); + + test('logs a debug message when sources remain without dependent tests', function (t) { + t.plan(2); + seed(); + + change('cannot-be-mapped.js'); + return debounce().then(function () { + t.ok(debug.calledTwice); + t.same(debug.secondCall.args, ['ava:watcher', 'Sources remain that cannot be traced to specific tests. Rerunning all tests']); + }); + }); + }); });