UNPKG

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