UNPKG

11.3 kBJavaScriptView Raw
1'use strict';
2const nodePath = require('path');
3const debug = require('debug')('ava:watcher');
4const chokidar = require('chokidar');
5const diff = require('lodash/difference');
6const flatten = require('lodash/flatten');
7const chalk = require('./chalk').get();
8const {applyTestFileFilter, classify, getChokidarIgnorePatterns} = require('./globs');
9const {levels: providerLevels} = require('./provider-manager');
10
11function rethrowAsync(err) {
12 // Don't swallow exceptions. Note that any
13 // expected error should already have been logged
14 setImmediate(() => {
15 throw err;
16 });
17}
18
19const MIN_DEBOUNCE_DELAY = 10;
20const INITIAL_DEBOUNCE_DELAY = 100;
21const END_MESSAGE = chalk.gray('Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n');
22
23class Debouncer {
24 constructor(watcher) {
25 this.watcher = watcher;
26 this.timer = null;
27 this.repeat = false;
28 }
29
30 debounce(delay) {
31 if (this.timer) {
32 this.again = true;
33 return;
34 }
35
36 delay = delay ? Math.max(delay, MIN_DEBOUNCE_DELAY) : INITIAL_DEBOUNCE_DELAY;
37
38 const timer = setTimeout(async () => {
39 await this.watcher.busy;
40 // Do nothing if debouncing was canceled while waiting for the busy
41 // promise to fulfil
42 if (this.timer !== timer) {
43 return;
44 }
45
46 if (this.again) {
47 this.timer = null;
48 this.again = false;
49 this.debounce(delay / 2);
50 } else {
51 this.watcher.runAfterChanges();
52 this.timer = null;
53 this.again = false;
54 }
55 }, delay);
56
57 this.timer = timer;
58 }
59
60 cancel() {
61 if (this.timer) {
62 clearTimeout(this.timer);
63 this.timer = null;
64 this.again = false;
65 }
66 }
67}
68
69class TestDependency {
70 constructor(file, dependencies) {
71 this.file = file;
72 this.dependencies = dependencies;
73 }
74
75 contains(dependency) {
76 return this.dependencies.includes(dependency);
77 }
78}
79
80class Watcher {
81 constructor({api, filter = [], globs, projectDir, providers, reporter}) {
82 this.debouncer = new Debouncer(this);
83
84 this.clearLogOnNextRun = true;
85 this.runVector = 0;
86 this.previousFiles = [];
87 this.globs = {cwd: projectDir, ...globs};
88
89 const patternFilters = filter.map(({pattern}) => pattern);
90
91 this.providers = providers.filter(({level}) => level >= providerLevels.pathRewrites);
92 this.run = (specificFiles = [], updateSnapshots = false) => {
93 const clearLogOnNextRun = this.clearLogOnNextRun && this.runVector > 0;
94 if (this.runVector > 0) {
95 this.clearLogOnNextRun = true;
96 }
97
98 this.runVector++;
99
100 let runOnlyExclusive = false;
101 if (specificFiles.length > 0) {
102 const exclusiveFiles = specificFiles.filter(file => this.filesWithExclusiveTests.includes(file));
103 runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length;
104 if (runOnlyExclusive) {
105 // The test files that previously contained exclusive tests are always
106 // run, together with the remaining specific files.
107 const remainingFiles = diff(specificFiles, exclusiveFiles);
108 specificFiles = this.filesWithExclusiveTests.concat(remainingFiles);
109 }
110
111 if (filter.length > 0) {
112 specificFiles = applyTestFileFilter({cwd: projectDir, filter: patternFilters, testFiles: specificFiles});
113 }
114
115 this.pruneFailures(specificFiles);
116 }
117
118 this.touchedFiles.clear();
119 this.previousFiles = specificFiles;
120 this.busy = api.run({
121 files: specificFiles,
122 filter,
123 runtimeOptions: {
124 clearLogOnNextRun,
125 previousFailures: this.sumPreviousFailures(this.runVector),
126 runOnlyExclusive,
127 runVector: this.runVector,
128 updateSnapshots: updateSnapshots === true
129 }
130 })
131 .then(runStatus => { // eslint-disable-line promise/prefer-await-to-then
132 reporter.endRun();
133 reporter.lineWriter.writeLine(END_MESSAGE);
134
135 if (this.clearLogOnNextRun && (
136 runStatus.stats.failedHooks > 0 ||
137 runStatus.stats.failedTests > 0 ||
138 runStatus.stats.failedWorkers > 0 ||
139 runStatus.stats.internalErrors > 0 ||
140 runStatus.stats.timeouts > 0 ||
141 runStatus.stats.uncaughtExceptions > 0 ||
142 runStatus.stats.unhandledRejections > 0
143 )) {
144 this.clearLogOnNextRun = false;
145 }
146 })
147 .catch(rethrowAsync);
148 };
149
150 this.testDependencies = [];
151 this.trackTestDependencies(api);
152
153 this.touchedFiles = new Set();
154 this.trackTouchedFiles(api);
155
156 this.filesWithExclusiveTests = [];
157 this.trackExclusivity(api);
158
159 this.filesWithFailures = [];
160 this.trackFailures(api);
161
162 this.dirtyStates = {};
163 this.watchFiles();
164 this.rerunAll();
165 }
166
167 watchFiles() {
168 chokidar.watch(['**/*'], {
169 cwd: this.globs.cwd,
170 ignored: getChokidarIgnorePatterns(this.globs),
171 ignoreInitial: true
172 }).on('all', (event, path) => {
173 if (event === 'add' || event === 'change' || event === 'unlink') {
174 debug('Detected %s of %s', event, path);
175 this.dirtyStates[nodePath.join(this.globs.cwd, path)] = event;
176 this.debouncer.debounce();
177 }
178 });
179 }
180
181 trackTestDependencies(api) {
182 api.on('run', plan => {
183 plan.status.on('stateChange', evt => {
184 if (evt.type !== 'dependencies') {
185 return;
186 }
187
188 const dependencies = evt.dependencies.filter(filePath => {
189 const {isIgnoredByWatcher} = classify(filePath, this.globs);
190 return !isIgnoredByWatcher;
191 });
192 this.updateTestDependencies(evt.testFile, dependencies);
193 });
194 });
195 }
196
197 updateTestDependencies(file, dependencies) {
198 // Ensure the rewritten test file path is included in the dependencies,
199 // since changes to non-rewritten paths are ignored.
200 for (const {main} of this.providers) {
201 const rewritten = main.resolveTestFile(file);
202 if (!dependencies.includes(rewritten)) {
203 dependencies = [rewritten, ...dependencies];
204 }
205 }
206
207 if (dependencies.length === 0) {
208 this.testDependencies = this.testDependencies.filter(dep => dep.file !== file);
209 return;
210 }
211
212 const isUpdate = this.testDependencies.some(dep => {
213 if (dep.file !== file) {
214 return false;
215 }
216
217 dep.dependencies = dependencies;
218
219 return true;
220 });
221
222 if (!isUpdate) {
223 this.testDependencies.push(new TestDependency(file, dependencies));
224 }
225 }
226
227 trackTouchedFiles(api) {
228 api.on('run', plan => {
229 plan.status.on('stateChange', evt => {
230 if (evt.type !== 'touched-files') {
231 return;
232 }
233
234 for (const file of evt.files) {
235 this.touchedFiles.add(file);
236 }
237 });
238 });
239 }
240
241 trackExclusivity(api) {
242 api.on('run', plan => {
243 plan.status.on('stateChange', evt => {
244 if (evt.type !== 'worker-finished') {
245 return;
246 }
247
248 const fileStats = plan.status.stats.byFile.get(evt.testFile);
249 const ranExclusiveTests = fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests;
250 this.updateExclusivity(evt.testFile, ranExclusiveTests);
251 });
252 });
253 }
254
255 updateExclusivity(file, hasExclusiveTests) {
256 const index = this.filesWithExclusiveTests.indexOf(file);
257
258 if (hasExclusiveTests && index === -1) {
259 this.filesWithExclusiveTests.push(file);
260 } else if (!hasExclusiveTests && index !== -1) {
261 this.filesWithExclusiveTests.splice(index, 1);
262 }
263 }
264
265 trackFailures(api) {
266 api.on('run', plan => {
267 this.pruneFailures(plan.files);
268
269 const currentVector = this.runVector;
270 plan.status.on('stateChange', evt => {
271 if (!evt.testFile) {
272 return;
273 }
274
275 switch (evt.type) {
276 case 'hook-failed':
277 case 'internal-error':
278 case 'test-failed':
279 case 'uncaught-exception':
280 case 'unhandled-rejection':
281 case 'worker-failed':
282 this.countFailure(evt.testFile, currentVector);
283 break;
284 default:
285 break;
286 }
287 });
288 });
289 }
290
291 pruneFailures(files) {
292 const toPrune = new Set(files);
293 this.filesWithFailures = this.filesWithFailures.filter(state => !toPrune.has(state.file));
294 }
295
296 countFailure(file, vector) {
297 const isUpdate = this.filesWithFailures.some(state => {
298 if (state.file !== file) {
299 return false;
300 }
301
302 state.count++;
303 return true;
304 });
305
306 if (!isUpdate) {
307 this.filesWithFailures.push({
308 file,
309 vector,
310 count: 1
311 });
312 }
313 }
314
315 sumPreviousFailures(beforeVector) {
316 let total = 0;
317
318 for (const state of this.filesWithFailures) {
319 if (state.vector < beforeVector) {
320 total += state.count;
321 }
322 }
323
324 return total;
325 }
326
327 cleanUnlinkedTests(unlinkedTests) {
328 for (const testFile of unlinkedTests) {
329 this.updateTestDependencies(testFile, []);
330 this.updateExclusivity(testFile, false);
331 this.pruneFailures([testFile]);
332 }
333 }
334
335 observeStdin(stdin) {
336 stdin.resume();
337 stdin.setEncoding('utf8');
338
339 stdin.on('data', async data => {
340 data = data.trim().toLowerCase();
341 if (data !== 'r' && data !== 'rs' && data !== 'u') {
342 return;
343 }
344
345 // Cancel the debouncer, it might rerun specific tests whereas *all* tests
346 // need to be rerun
347 this.debouncer.cancel();
348 await this.busy;
349 // Cancel the debouncer again, it might have restarted while waiting for
350 // the busy promise to fulfil
351 this.debouncer.cancel();
352 this.clearLogOnNextRun = false;
353 if (data === 'u') {
354 this.updatePreviousSnapshots();
355 } else {
356 this.rerunAll();
357 }
358 });
359 }
360
361 rerunAll() {
362 this.dirtyStates = {};
363 this.run();
364 }
365
366 updatePreviousSnapshots() {
367 this.dirtyStates = {};
368 this.run(this.previousFiles, true);
369 }
370
371 runAfterChanges() {
372 const {dirtyStates} = this;
373 this.dirtyStates = {};
374
375 let dirtyPaths = Object.keys(dirtyStates).filter(path => {
376 if (this.touchedFiles.has(path)) {
377 debug('Ignoring known touched file %s', path);
378 this.touchedFiles.delete(path);
379 return false;
380 }
381
382 return true;
383 });
384
385 for (const {main} of this.providers) {
386 dirtyPaths = dirtyPaths.filter(path => {
387 if (main.ignoreChange(path)) {
388 debug('Ignoring changed file %s', path);
389 return false;
390 }
391
392 return true;
393 });
394 }
395
396 const dirtyHelpersAndSources = [];
397 const dirtyTests = [];
398 for (const filePath of dirtyPaths) {
399 const {isIgnoredByWatcher, isTest} = classify(filePath, this.globs);
400 if (!isIgnoredByWatcher) {
401 if (isTest) {
402 dirtyTests.push(filePath);
403 } else {
404 dirtyHelpersAndSources.push(filePath);
405 }
406 }
407 }
408
409 const addedOrChangedTests = dirtyTests.filter(path => dirtyStates[path] !== 'unlink');
410 const unlinkedTests = diff(dirtyTests, addedOrChangedTests);
411
412 this.cleanUnlinkedTests(unlinkedTests);
413
414 // No need to rerun tests if the only change is that tests were deleted
415 if (unlinkedTests.length === dirtyPaths.length) {
416 return;
417 }
418
419 if (dirtyHelpersAndSources.length === 0) {
420 // Run any new or changed tests
421 this.run(addedOrChangedTests);
422 return;
423 }
424
425 // Try to find tests that depend on the changed source files
426 const testsByHelpersOrSource = dirtyHelpersAndSources.map(path => {
427 return this.testDependencies.filter(dep => dep.contains(path)).map(dep => {
428 debug('%s is a dependency of %s', path, dep.file);
429 return dep.file;
430 });
431 }, this).filter(tests => tests.length > 0);
432
433 // Rerun all tests if source files were changed that could not be traced to
434 // specific tests
435 if (testsByHelpersOrSource.length !== dirtyHelpersAndSources.length) {
436 debug('Files remain that cannot be traced to specific tests: %O', dirtyHelpersAndSources);
437 debug('Rerunning all tests');
438 this.run();
439 return;
440 }
441
442 // Run all affected tests
443 this.run([...new Set(addedOrChangedTests.concat(flatten(testsByHelpersOrSource)))]);
444 }
445}
446
447module.exports = Watcher;