UNPKG

9.95 kBJavaScriptView Raw
1'use strict';
2
3var AvaError = require('./ava-error');
4var debug = require('debug')('ava:watcher');
5var diff = require('arr-diff');
6var flatten = require('arr-flatten');
7var union = require('array-union');
8var uniq = require('array-uniq');
9var defaultIgnore = require('ignore-by-default').directories();
10var multimatch = require('multimatch');
11var nodePath = require('path');
12var slash = require('slash');
13
14function requireChokidar() {
15 try {
16 return require('chokidar');
17 } catch (err) {
18 throw new AvaError('The optional dependency chokidar failed to install and is required for --watch. Chokidar is likely not supported on your platform.');
19 }
20}
21
22function rethrowAsync(err) {
23 // Don't swallow exceptions. Note that any expected error should already have
24 // been logged.
25 setImmediate(function () {
26 throw err;
27 });
28}
29
30// Used on paths before they're passed to multimatch to Harmonize matching
31// across platforms.
32var matchable = process.platform === 'win32' ? slash : function (path) {
33 return path;
34};
35
36function Watcher(logger, api, files, sources) {
37 this.debouncer = new Debouncer(this);
38
39 this.isTest = makeTestMatcher(files, api.excludePatterns);
40 this.run = function (specificFiles) {
41 logger.reset();
42
43 var runOnlyExclusive = false;
44 if (specificFiles) {
45 var exclusiveFiles = specificFiles.filter(function (file) {
46 return this.filesWithExclusiveTests.indexOf(file) !== -1;
47 }, this);
48
49 runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length;
50 }
51
52 this.busy = api.run(specificFiles || files, {
53 runOnlyExclusive: runOnlyExclusive
54 }).then(function () {
55 logger.finish();
56 }, rethrowAsync);
57 };
58
59 this.testDependencies = [];
60 this.trackTestDependencies(api, sources);
61
62 this.filesWithExclusiveTests = [];
63 this.trackExclusivity(api);
64
65 this.dirtyStates = {};
66 this.watchFiles(files, sources);
67 this.rerunAll();
68}
69
70module.exports = Watcher;
71
72Watcher.prototype.watchFiles = function (files, sources) {
73 var patterns = getChokidarPatterns(files, sources);
74
75 var self = this;
76 requireChokidar().watch(patterns.paths, {
77 ignored: patterns.ignored,
78 ignoreInitial: true
79 }).on('all', function (event, path) {
80 if (event === 'add' || event === 'change' || event === 'unlink') {
81 debug('Detected %s of %s', event, path);
82 self.dirtyStates[path] = event;
83 self.debouncer.debounce();
84 }
85 });
86};
87
88Watcher.prototype.trackTestDependencies = function (api, sources) {
89 var isSource = makeSourceMatcher(sources);
90
91 var cwd = process.cwd();
92 var relative = function (absPath) {
93 return nodePath.relative(cwd, absPath);
94 };
95
96 var self = this;
97 api.on('dependencies', function (file, dependencies) {
98 var sourceDeps = dependencies.map(relative).filter(isSource);
99 self.updateTestDependencies(file, sourceDeps);
100 });
101};
102
103Watcher.prototype.updateTestDependencies = function (file, sources) {
104 if (sources.length === 0) {
105 this.testDependencies = this.testDependencies.filter(function (dep) {
106 return dep.file !== file;
107 });
108 return;
109 }
110
111 var isUpdate = this.testDependencies.some(function (dep) {
112 if (dep.file !== file) {
113 return false;
114 }
115
116 dep.sources = sources;
117 return true;
118 });
119
120 if (!isUpdate) {
121 this.testDependencies.push(new TestDependency(file, sources));
122 }
123};
124
125Watcher.prototype.trackExclusivity = function (api) {
126 var self = this;
127 api.on('stats', function (stats) {
128 self.updateExclusivity(stats.file, stats.hasExclusive);
129 });
130};
131
132Watcher.prototype.updateExclusivity = function (file, hasExclusiveTests) {
133 var index = this.filesWithExclusiveTests.indexOf(file);
134
135 if (hasExclusiveTests && index === -1) {
136 this.filesWithExclusiveTests.push(file);
137 } else if (!hasExclusiveTests && index !== -1) {
138 this.filesWithExclusiveTests.splice(index, 1);
139 }
140};
141
142Watcher.prototype.cleanUnlinkedTests = function (unlinkedTests) {
143 unlinkedTests.forEach(function (testFile) {
144 this.updateTestDependencies(testFile, []);
145 this.updateExclusivity(testFile, false);
146 }, this);
147};
148
149Watcher.prototype.observeStdin = function (stdin) {
150 var self = this;
151
152 stdin.resume();
153 stdin.setEncoding('utf8');
154 stdin.on('data', function (data) {
155 data = data.trim().toLowerCase();
156 if (data !== 'r' && data !== 'rs') {
157 return;
158 }
159
160 // Cancel the debouncer, it might rerun specific tests whereas *all* tests
161 // need to be rerun.
162 self.debouncer.cancel();
163 self.busy.then(function () {
164 // Cancel the debouncer again, it might have restarted while waiting for
165 // the busy promise to fulfil.
166 self.debouncer.cancel();
167 self.rerunAll();
168 });
169 });
170};
171
172Watcher.prototype.rerunAll = function () {
173 this.dirtyStates = {};
174 this.run();
175};
176
177Watcher.prototype.runAfterChanges = function () {
178 var dirtyStates = this.dirtyStates;
179 this.dirtyStates = {};
180
181 var dirtyPaths = Object.keys(dirtyStates);
182 var dirtyTests = dirtyPaths.filter(this.isTest);
183 var dirtySources = diff(dirtyPaths, dirtyTests);
184 var addedOrChangedTests = dirtyTests.filter(function (path) {
185 return dirtyStates[path] !== 'unlink';
186 });
187 var unlinkedTests = diff(dirtyTests, addedOrChangedTests);
188
189 this.cleanUnlinkedTests(unlinkedTests);
190 // No need to rerun tests if the only change is that tests were deleted.
191 if (unlinkedTests.length === dirtyPaths.length) {
192 return;
193 }
194
195 if (dirtySources.length === 0) {
196 // Run any new or changed tests.
197 this.run(addedOrChangedTests);
198 return;
199 }
200
201 // Try to find tests that depend on the changed source files.
202 var testsBySource = dirtySources.map(function (path) {
203 return this.testDependencies.filter(function (dep) {
204 return dep.contains(path);
205 }).map(function (dep) {
206 debug('%s is a dependency of %s', path, dep.file);
207 return dep.file;
208 });
209 }, this).filter(function (tests) {
210 return tests.length > 0;
211 });
212
213 // Rerun all tests if source files were changed that could not be traced to
214 // specific tests.
215 if (testsBySource.length !== dirtySources.length) {
216 debug('Sources remain that cannot be traced to specific tests. Rerunning all tests');
217 this.run();
218 return;
219 }
220
221 // Run all affected tests.
222 this.run(union(addedOrChangedTests, uniq(flatten(testsBySource))));
223};
224
225function Debouncer(watcher) {
226 this.watcher = watcher;
227
228 this.timer = null;
229 this.repeat = false;
230}
231
232Debouncer.prototype.debounce = function () {
233 if (this.timer) {
234 this.again = true;
235 return;
236 }
237
238 var self = this;
239 var timer = this.timer = setTimeout(function () {
240 self.watcher.busy.then(function () {
241 // Do nothing if debouncing was canceled while waiting for the busy
242 // promise to fulfil.
243 if (self.timer !== timer) {
244 return;
245 }
246
247 if (self.again) {
248 self.timer = null;
249 self.again = false;
250 self.debounce();
251 } else {
252 self.watcher.runAfterChanges();
253 self.timer = null;
254 self.again = false;
255 }
256 });
257 }, 10);
258};
259
260Debouncer.prototype.cancel = function () {
261 if (this.timer) {
262 clearTimeout(this.timer);
263 this.timer = null;
264 this.again = false;
265 }
266};
267
268function getChokidarPatterns(files, sources) {
269 var paths = [];
270 var ignored = [];
271
272 sources.forEach(function (pattern) {
273 if (pattern[0] === '!') {
274 ignored.push(pattern.slice(1));
275 } else {
276 paths.push(pattern);
277 }
278 });
279
280 if (paths.length === 0) {
281 paths = ['package.json', '**/*.js'];
282 }
283 paths = paths.concat(files);
284
285 if (ignored.length === 0) {
286 ignored = defaultIgnore;
287 }
288
289 return {paths: paths, ignored: ignored};
290}
291
292function makeSourceMatcher(sources) {
293 var patterns = sources;
294
295 var hasPositivePattern = patterns.some(function (pattern) {
296 return pattern[0] !== '!';
297 });
298 var hasNegativePattern = patterns.some(function (pattern) {
299 return pattern[0] === '!';
300 });
301
302 // Same defaults as used for Chokidar.
303 if (!hasPositivePattern) {
304 patterns = ['package.json', '**/*.js'].concat(patterns);
305 }
306 if (!hasNegativePattern) {
307 patterns = patterns.concat(defaultIgnore.map(function (dir) {
308 return '!' + dir + '/**/*';
309 }));
310 }
311
312 return function (path) {
313 // Ignore paths outside the current working directory. They can't be matched
314 // to a pattern.
315 if (/^\.\./.test(path)) {
316 return false;
317 }
318
319 return multimatch(matchable(path), patterns).length === 1;
320 };
321}
322
323function makeTestMatcher(files, excludePatterns) {
324 var initialPatterns = files.concat(excludePatterns);
325 return function (path) {
326 // Like in api.js, tests must be .js files and not start with _
327 if (nodePath.extname(path) !== '.js' || nodePath.basename(path)[0] === '_') {
328 return false;
329 }
330
331 // Check if the entire path matches a pattern.
332 if (multimatch(matchable(path), initialPatterns).length === 1) {
333 return true;
334 }
335
336 // Check if the path contains any directory components.
337 var dirname = nodePath.dirname(path);
338 if (dirname === '.') {
339 return false;
340 }
341
342 // Compute all possible subpaths. Note that the dirname is assumed to be
343 // relative to the working directory, without a leading `./`.
344 var subpaths = dirname.split(nodePath.sep).reduce(function (subpaths, component) {
345 var parent = subpaths[subpaths.length - 1];
346 if (parent) {
347 // Always use / to makes multimatch consistent across platforms.
348 subpaths.push(parent + '/' + component);
349 } else {
350 subpaths.push(component);
351 }
352 return subpaths;
353 }, []);
354
355 // Check if any of the possible subpaths match a pattern. If so, generate a
356 // new pattern with **/*.js.
357 var recursivePatterns = subpaths.filter(function (subpath) {
358 return multimatch(subpath, initialPatterns).length === 1;
359 }).map(function (subpath) {
360 // Always use / to makes multimatch consistent across platforms.
361 return subpath + '**/*.js';
362 });
363
364 // See if the entire path matches any of the subpaths patterns, taking the
365 // excludePatterns into account. This mimicks the behavior in api.js
366 return multimatch(matchable(path), recursivePatterns.concat(excludePatterns)).length === 1;
367 };
368}
369
370function TestDependency(file, sources) {
371 this.file = file;
372 this.sources = sources;
373}
374
375TestDependency.prototype.contains = function (source) {
376 return this.sources.indexOf(source) !== -1;
377};