1 | 'use strict';
|
2 | const nodePath = require('path');
|
3 | const debug = require('debug')('ava:watcher');
|
4 | const chokidar = require('chokidar');
|
5 | const diff = require('lodash/difference');
|
6 | const flatten = require('lodash/flatten');
|
7 | const chalk = require('./chalk').get();
|
8 | const {applyTestFileFilter, classify, getChokidarIgnorePatterns} = require('./globs');
|
9 | const {levels: providerLevels} = require('./provider-manager');
|
10 |
|
11 | function rethrowAsync(err) {
|
12 |
|
13 |
|
14 | setImmediate(() => {
|
15 | throw err;
|
16 | });
|
17 | }
|
18 |
|
19 | const MIN_DEBOUNCE_DELAY = 10;
|
20 | const INITIAL_DEBOUNCE_DELAY = 100;
|
21 | const END_MESSAGE = chalk.gray('Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n');
|
22 |
|
23 | class 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 |
|
41 |
|
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 |
|
69 | class 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 |
|
80 | class 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 |
|
106 |
|
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 => {
|
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 |
|
199 |
|
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 |
|
346 |
|
347 | this.debouncer.cancel();
|
348 | await this.busy;
|
349 |
|
350 |
|
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 |
|
415 | if (unlinkedTests.length === dirtyPaths.length) {
|
416 | return;
|
417 | }
|
418 |
|
419 | if (dirtyHelpersAndSources.length === 0) {
|
420 |
|
421 | this.run(addedOrChangedTests);
|
422 | return;
|
423 | }
|
424 |
|
425 |
|
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 |
|
434 |
|
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 |
|
443 | this.run([...new Set(addedOrChangedTests.concat(flatten(testsByHelpersOrSource)))]);
|
444 | }
|
445 | }
|
446 |
|
447 | module.exports = Watcher;
|