'use strict'; var nodePath = require('path'); 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 AvaError = require('./ava-error'); var AvaFiles = require('./ava-files'); function requireChokidar() { try { return require('chokidar'); } catch (err) { throw new AvaError('The optional dependency chokidar failed to install and is required for --watch. Chokidar is likely not supported on your platform.'); } } function rethrowAsync(err) { // Don't swallow exceptions. Note that any expected error should already have // been logged. setImmediate(function () { throw err; }); } function Watcher(logger, api, files, sources) { this.debouncer = new Debouncer(this); this.avaFiles = new AvaFiles(files, sources); this.isTest = this.avaFiles.makeTestMatcher(); this.clearLogOnNextRun = true; this.runVector = 0; this.run = function (specificFiles) { if (this.runVector > 0) { var cleared = this.clearLogOnNextRun && logger.clear(); if (!cleared) { logger.reset(); logger.section(); } this.clearLogOnNextRun = true; logger.reset(); logger.start(); } var currentVector = this.runVector += 1; var runOnlyExclusive = false; if (specificFiles) { var exclusiveFiles = specificFiles.filter(function (file) { return this.filesWithExclusiveTests.indexOf(file) !== -1; }, this); runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length; if (runOnlyExclusive) { // The test files that previously contained exclusive tests are always // run, together with the remaining specific files. var remainingFiles = diff(specificFiles, exclusiveFiles); specificFiles = this.filesWithExclusiveTests.concat(remainingFiles); } } var self = this; this.busy = api.run(specificFiles || files, { runOnlyExclusive: runOnlyExclusive }).then(function (runStatus) { runStatus.previousFailCount = self.sumPreviousFailures(currentVector); logger.finish(runStatus); var badCounts = runStatus.failCount + runStatus.rejectionCount + runStatus.exceptionCount; self.clearLogOnNextRun = self.clearLogOnNextRun && badCounts === 0; }, rethrowAsync); }; this.testDependencies = []; this.trackTestDependencies(api, sources); this.filesWithExclusiveTests = []; this.trackExclusivity(api); this.filesWithFailures = []; this.trackFailures(api); this.dirtyStates = {}; this.watchFiles(); this.rerunAll(); } module.exports = Watcher; Watcher.prototype.watchFiles = function () { var self = this; var patterns = this.avaFiles.getChokidarPatterns(); requireChokidar().watch(patterns.paths, { ignored: patterns.ignored, ignoreInitial: true }).on('all', function (event, path) { if (event === 'add' || event === 'change' || event === 'unlink') { debug('Detected %s of %s', event, path); self.dirtyStates[path] = event; self.debouncer.debounce(); } }); }; Watcher.prototype.trackTestDependencies = function (api) { var self = this; var isSource = this.avaFiles.makeSourceMatcher(); var relative = function (absPath) { return nodePath.relative('.', absPath); }; api.on('test-run', function (runStatus) { runStatus.on('dependencies', function (file, dependencies) { var sourceDeps = dependencies.map(relative).filter(isSource); self.updateTestDependencies(file, sourceDeps); }); }); }; Watcher.prototype.updateTestDependencies = function (file, sources) { if (sources.length === 0) { this.testDependencies = this.testDependencies.filter(function (dep) { return dep.file !== file; }); return; } var isUpdate = this.testDependencies.some(function (dep) { if (dep.file !== file) { return false; } dep.sources = sources; return true; }); if (!isUpdate) { this.testDependencies.push(new TestDependency(file, sources)); } }; Watcher.prototype.trackExclusivity = function (api) { var self = this; api.on('stats', function (stats) { self.updateExclusivity(stats.file, stats.hasExclusive); }); }; Watcher.prototype.updateExclusivity = function (file, hasExclusiveTests) { var index = this.filesWithExclusiveTests.indexOf(file); if (hasExclusiveTests && index === -1) { this.filesWithExclusiveTests.push(file); } else if (!hasExclusiveTests && index !== -1) { this.filesWithExclusiveTests.splice(index, 1); } }; Watcher.prototype.trackFailures = function (api) { var self = this; api.on('test-run', function (runStatus, files) { files.forEach(function (file) { self.pruneFailures(nodePath.relative('.', file)); }); var currentVector = self.runVector; runStatus.on('error', function (err) { self.countFailure(err.file, currentVector); }); runStatus.on('test', function (result) { if (result.error) { self.countFailure(result.file, currentVector); } }); }); }; Watcher.prototype.pruneFailures = function (file) { this.filesWithFailures = this.filesWithFailures.filter(function (state) { return state.file !== file; }); }; Watcher.prototype.countFailure = function (file, vector) { var isUpdate = this.filesWithFailures.some(function (state) { if (state.file !== file) { return false; } state.count++; return true; }); if (!isUpdate) { this.filesWithFailures.push({ file: file, vector: vector, count: 1 }); } }; Watcher.prototype.sumPreviousFailures = function (beforeVector) { var total = 0; this.filesWithFailures.forEach(function (state) { if (state.vector < beforeVector) { total += state.count; } }); return total; }; Watcher.prototype.cleanUnlinkedTests = function (unlinkedTests) { unlinkedTests.forEach(function (testFile) { this.updateTestDependencies(testFile, []); this.updateExclusivity(testFile, false); this.pruneFailures(testFile); }, this); }; Watcher.prototype.observeStdin = function (stdin) { var self = this; stdin.resume(); stdin.setEncoding('utf8'); stdin.on('data', function (data) { data = data.trim().toLowerCase(); if (data !== 'r' && data !== 'rs') { return; } // Cancel the debouncer, it might rerun specific tests whereas *all* tests // need to be rerun. self.debouncer.cancel(); self.busy.then(function () { // Cancel the debouncer again, it might have restarted while waiting for // the busy promise to fulfil. self.debouncer.cancel(); self.clearLogOnNextRun = false; 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.cleanUnlinkedTests(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 TestDependency(file, sources) { this.file = file; this.sources = sources; } TestDependency.prototype.contains = function (source) { return this.sources.indexOf(source) !== -1; };