UNPKG

ava

Version:

Futuristic test runner 🚀

378 lines (316 loc) • 9.95 kB
'use strict'; 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 slash = require('slash'); 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; }); } // Used on paths before they're passed to multimatch to Harmonize matching // across platforms. var matchable = process.platform === 'win32' ? slash : function (path) { return path; }; function Watcher(logger, api, files, sources) { this.debouncer = new Debouncer(this); this.isTest = makeTestMatcher(files, api.excludePatterns); this.run = function (specificFiles) { logger.reset(); var runOnlyExclusive = false; if (specificFiles) { var exclusiveFiles = specificFiles.filter(function (file) { return this.filesWithExclusiveTests.indexOf(file) !== -1; }, this); runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length; } this.busy = api.run(specificFiles || files, { runOnlyExclusive: runOnlyExclusive }).then(function () { logger.finish(); }, rethrowAsync); }; this.testDependencies = []; this.trackTestDependencies(api, sources); this.filesWithExclusiveTests = []; this.trackExclusivity(api); this.dirtyStates = {}; this.watchFiles(files, sources); this.rerunAll(); } module.exports = Watcher; Watcher.prototype.watchFiles = function (files, sources) { var patterns = getChokidarPatterns(files, sources); var self = this; 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, sources) { var isSource = makeSourceMatcher(sources); var cwd = process.cwd(); var relative = function (absPath) { return nodePath.relative(cwd, absPath); }; var self = this; api.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.cleanUnlinkedTests = function (unlinkedTests) { unlinkedTests.forEach(function (testFile) { this.updateTestDependencies(testFile, []); this.updateExclusivity(testFile, false); }, 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.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 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) { // 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(matchable(path), initialPatterns).length === 1) { return true; } // Check if the path contains any directory components. var dirname = nodePath.dirname(path); if (dirname === '.') { return false; } // Compute all possible subpaths. Note that the dirname is assumed to be // relative to the working directory, without a leading `./`. var subpaths = dirname.split(nodePath.sep).reduce(function (subpaths, component) { var parent = subpaths[subpaths.length - 1]; if (parent) { // Always use / to makes multimatch consistent across platforms. subpaths.push(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) { // 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(matchable(path), recursivePatterns.concat(excludePatterns)).length === 1; }; } function TestDependency(file, sources) { this.file = file; this.sources = sources; } TestDependency.prototype.contains = function (source) { return this.sources.indexOf(source) !== -1; };