123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404 |
- 'use strict';
- const nodePath = require('path');
- const debug = require('debug')('ava:watcher');
- const diff = require('lodash.difference');
- const chokidar = require('chokidar');
- const flatten = require('arr-flatten');
- const union = require('array-union');
- const uniq = require('array-uniq');
- const AvaFiles = require('./ava-files');
- function rethrowAsync(err) {
- // Don't swallow exceptions. Note that any
- // expected error should already have been logged
- setImmediate(() => {
- throw err;
- });
- }
- const MIN_DEBOUNCE_DELAY = 10;
- const INITIAL_DEBOUNCE_DELAY = 100;
- class Debouncer {
- constructor(watcher) {
- this.watcher = watcher;
- this.timer = null;
- this.repeat = false;
- }
- debounce(delay) {
- if (this.timer) {
- this.again = true;
- return;
- }
- delay = delay ? Math.max(delay, MIN_DEBOUNCE_DELAY) : INITIAL_DEBOUNCE_DELAY;
- const timer = setTimeout(() => {
- this.watcher.busy.then(() => {
- // Do nothing if debouncing was canceled while waiting for the busy
- // promise to fulfil
- if (this.timer !== timer) {
- return;
- }
- if (this.again) {
- this.timer = null;
- this.again = false;
- this.debounce(delay / 2);
- } else {
- this.watcher.runAfterChanges();
- this.timer = null;
- this.again = false;
- }
- });
- }, delay);
- this.timer = timer;
- }
- cancel() {
- if (this.timer) {
- clearTimeout(this.timer);
- this.timer = null;
- this.again = false;
- }
- }
- }
- class TestDependency {
- constructor(file, sources) {
- this.file = file;
- this.sources = sources;
- }
- contains(source) {
- return this.sources.indexOf(source) !== -1;
- }
- }
- class Watcher {
- constructor(reporter, api, files, sources) {
- this.debouncer = new Debouncer(this);
- this.avaFiles = new AvaFiles({
- files,
- sources
- });
- this.clearLogOnNextRun = true;
- this.runVector = 0;
- this.previousFiles = files;
- this.run = (specificFiles, updateSnapshots) => {
- const clearLogOnNextRun = this.clearLogOnNextRun && this.runVector > 0;
- if (this.runVector > 0) {
- this.clearLogOnNextRun = true;
- }
- this.runVector++;
- let runOnlyExclusive = false;
- if (specificFiles) {
- const exclusiveFiles = specificFiles.filter(file => this.filesWithExclusiveTests.indexOf(file) !== -1);
- 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.
- const remainingFiles = diff(specificFiles, exclusiveFiles);
- specificFiles = this.filesWithExclusiveTests.concat(remainingFiles);
- }
- this.pruneFailures(specificFiles);
- }
- this.touchedFiles.clear();
- this.previousFiles = specificFiles || files;
- this.busy = api.run(this.previousFiles, {
- clearLogOnNextRun,
- previousFailures: this.sumPreviousFailures(this.runVector),
- runOnlyExclusive,
- runVector: this.runVector,
- updateSnapshots: updateSnapshots === true
- })
- .then(runStatus => {
- reporter.endRun();
- if (this.clearLogOnNextRun && (
- runStatus.stats.failedHooks > 0 ||
- runStatus.stats.failedTests > 0 ||
- runStatus.stats.failedWorkers > 0 ||
- runStatus.stats.internalErrors > 0 ||
- runStatus.stats.timeouts > 0 ||
- runStatus.stats.uncaughtExceptions > 0 ||
- runStatus.stats.unhandledRejections > 0
- )) {
- this.clearLogOnNextRun = false;
- }
- })
- .catch(rethrowAsync);
- };
- this.testDependencies = [];
- this.trackTestDependencies(api, sources);
- this.touchedFiles = new Set();
- this.trackTouchedFiles(api);
- this.filesWithExclusiveTests = [];
- this.trackExclusivity(api);
- this.filesWithFailures = [];
- this.trackFailures(api);
- this.dirtyStates = {};
- this.watchFiles();
- this.rerunAll();
- }
- watchFiles() {
- const patterns = this.avaFiles.getChokidarPatterns();
- chokidar.watch(patterns.paths, {
- ignored: patterns.ignored,
- ignoreInitial: true
- }).on('all', (event, path) => {
- if (event === 'add' || event === 'change' || event === 'unlink') {
- debug('Detected %s of %s', event, path);
- this.dirtyStates[path] = event;
- this.debouncer.debounce();
- }
- });
- }
- trackTestDependencies(api) {
- const relative = absPath => nodePath.relative(process.cwd(), absPath);
- api.on('run', plan => {
- plan.status.on('stateChange', evt => {
- if (evt.type !== 'dependencies') {
- return;
- }
- const sourceDeps = evt.dependencies.map(x => relative(x)).filter(this.avaFiles.isSource);
- this.updateTestDependencies(evt.testFile, sourceDeps);
- });
- });
- }
- updateTestDependencies(file, sources) {
- if (sources.length === 0) {
- this.testDependencies = this.testDependencies.filter(dep => dep.file !== file);
- return;
- }
- const isUpdate = this.testDependencies.some(dep => {
- if (dep.file !== file) {
- return false;
- }
- dep.sources = sources;
- return true;
- });
- if (!isUpdate) {
- this.testDependencies.push(new TestDependency(file, sources));
- }
- }
- trackTouchedFiles(api) {
- api.on('run', plan => {
- plan.status.on('stateChange', evt => {
- if (evt.type !== 'touched-files') {
- return;
- }
- for (const file of evt.files) {
- this.touchedFiles.add(nodePath.relative(process.cwd(), file));
- }
- });
- });
- }
- trackExclusivity(api) {
- api.on('run', plan => {
- plan.status.on('stateChange', evt => {
- if (evt.type !== 'worker-finished') {
- return;
- }
- const fileStats = plan.status.stats.byFile.get(evt.testFile);
- this.updateExclusivity(evt.testFile, fileStats.declaredTests > fileStats.selectedTests);
- });
- });
- }
- updateExclusivity(file, hasExclusiveTests) {
- const index = this.filesWithExclusiveTests.indexOf(file);
- if (hasExclusiveTests && index === -1) {
- this.filesWithExclusiveTests.push(file);
- } else if (!hasExclusiveTests && index !== -1) {
- this.filesWithExclusiveTests.splice(index, 1);
- }
- }
- trackFailures(api) {
- api.on('run', plan => {
- this.pruneFailures(plan.files);
- const currentVector = this.runVector;
- plan.status.on('stateChange', evt => {
- if (!evt.testFile) {
- return;
- }
- switch (evt.type) {
- case 'hook-failed':
- case 'internal-error':
- case 'test-failed':
- case 'uncaught-exception':
- case 'unhandled-rejection':
- case 'worker-failed':
- this.countFailure(evt.testFile, currentVector);
- break;
- default:
- break;
- }
- });
- });
- }
- pruneFailures(files) {
- const toPrune = new Set(files);
- this.filesWithFailures = this.filesWithFailures.filter(state => !toPrune.has(state.file));
- }
- countFailure(file, vector) {
- const isUpdate = this.filesWithFailures.some(state => {
- if (state.file !== file) {
- return false;
- }
- state.count++;
- return true;
- });
- if (!isUpdate) {
- this.filesWithFailures.push({
- file,
- vector,
- count: 1
- });
- }
- }
- sumPreviousFailures(beforeVector) {
- let total = 0;
- this.filesWithFailures.forEach(state => {
- if (state.vector < beforeVector) {
- total += state.count;
- }
- });
- return total;
- }
- cleanUnlinkedTests(unlinkedTests) {
- unlinkedTests.forEach(testFile => {
- this.updateTestDependencies(testFile, []);
- this.updateExclusivity(testFile, false);
- this.pruneFailures([testFile]);
- });
- }
- observeStdin(stdin) {
- stdin.resume();
- stdin.setEncoding('utf8');
- stdin.on('data', data => {
- data = data.trim().toLowerCase();
- if (data !== 'r' && data !== 'rs' && data !== 'u') {
- return;
- }
- // Cancel the debouncer, it might rerun specific tests whereas *all* tests
- // need to be rerun
- this.debouncer.cancel();
- this.busy.then(() => {
- // Cancel the debouncer again, it might have restarted while waiting for
- // the busy promise to fulfil
- this.debouncer.cancel();
- this.clearLogOnNextRun = false;
- if (data === 'u') {
- this.updatePreviousSnapshots();
- } else {
- this.rerunAll();
- }
- });
- });
- }
- rerunAll() {
- this.dirtyStates = {};
- this.run();
- }
- updatePreviousSnapshots() {
- this.dirtyStates = {};
- this.run(this.previousFiles, true);
- }
- runAfterChanges() {
- const dirtyStates = this.dirtyStates;
- this.dirtyStates = {};
- const dirtyPaths = Object.keys(dirtyStates).filter(path => {
- if (this.touchedFiles.has(path)) {
- debug('Ignoring known touched file %s', path);
- this.touchedFiles.delete(path);
- return false;
- }
- return true;
- });
- const dirtyTests = dirtyPaths.filter(this.avaFiles.isTest);
- const dirtySources = diff(dirtyPaths, dirtyTests);
- const addedOrChangedTests = dirtyTests.filter(path => dirtyStates[path] !== 'unlink');
- const 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
- const testsBySource = dirtySources.map(path => {
- return this.testDependencies.filter(dep => dep.contains(path)).map(dep => {
- debug('%s is a dependency of %s', path, dep.file);
- return dep.file;
- });
- }, this).filter(tests => 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: %O', dirtySources);
- debug('Rerunning all tests');
- this.run();
- return;
- }
- // Run all affected tests
- this.run(union(addedOrChangedTests, uniq(flatten(testsBySource))));
- }
- }
- module.exports = Watcher;
|