UNPKG

10.2 kBJavaScriptView Raw
1// File List
2// =========
3//
4// The List is an object for tracking all files that karma knows about
5// currently.
6
7// Dependencies
8// ------------
9
10var Promise = require('bluebird')
11var mm = require('minimatch')
12var Glob = require('glob').Glob
13var fs = Promise.promisifyAll(require('graceful-fs'))
14var pathLib = require('path')
15var _ = require('lodash')
16
17var File = require('./file')
18var Url = require('./url')
19var helper = require('./helper')
20var log = require('./logger').create('watcher')
21var createPatternObject = require('./config').createPatternObject
22
23// Constants
24// ---------
25
26var GLOB_OPTS = {
27 cwd: '/',
28 follow: true,
29 nodir: true,
30 sync: true
31}
32
33// Helper Functions
34// ----------------
35
36function byPath (a, b) {
37 if (a.path > b.path) return 1
38 if (a.path < b.path) return -1
39
40 return 0
41}
42
43// Constructor
44//
45// patterns - Array
46// excludes - Array
47// emitter - EventEmitter
48// preprocess - Function
49// batchInterval - Number
50var List = function (patterns, excludes, emitter, preprocess, autoWatchBatchDelay) {
51 // Store options
52 this._patterns = patterns
53 this._excludes = excludes
54 this._emitter = emitter
55 this._preprocess = Promise.promisify(preprocess)
56 this._autoWatchBatchDelay = autoWatchBatchDelay
57
58 // The actual list of files
59 this.buckets = new Map()
60
61 // Internal tracker if we are refreshing.
62 // When a refresh is triggered this gets set
63 // to the promise that `this._refresh` returns.
64 // So we know we are refreshing when this promise
65 // is still pending, and we are done when it's either
66 // resolved or rejected.
67 this._refreshing = Promise.resolve()
68
69 var self = this
70
71 // Emit the `file_list_modified` event.
72 // This function is debounced to the value of `autoWatchBatchDelay`
73 // to avoid reloading while files are still being modified.
74 function emit () {
75 self._emitter.emit('file_list_modified', self.files)
76 }
77 var debouncedEmit = _.debounce(emit, self._autoWatchBatchDelay)
78 self._emitModified = function (immediate) {
79 immediate ? emit() : debouncedEmit()
80 }
81}
82
83// Private Interface
84// -----------------
85
86// Is the given path matched by any exclusion filter
87//
88// path - String
89//
90// Returns `undefined` if no match, otherwise the matching
91// pattern.
92List.prototype._isExcluded = function (path) {
93 return _.find(this._excludes, function (pattern) {
94 return mm(path, pattern)
95 })
96}
97
98// Find the matching include pattern for the given path.
99//
100// path - String
101//
102// Returns the match or `undefined` if none found.
103List.prototype._isIncluded = function (path) {
104 return _.find(this._patterns, function (pattern) {
105 return mm(path, pattern.pattern)
106 })
107}
108
109// Find the given path in the bucket corresponding
110// to the given pattern.
111//
112// path - String
113// pattern - Object
114//
115// Returns a File or undefined
116List.prototype._findFile = function (path, pattern) {
117 if (!path || !pattern) return
118 if (!this.buckets.has(pattern.pattern)) return
119
120 return _.find(Array.from(this.buckets.get(pattern.pattern)), function (file) {
121 return file.originalPath === path
122 })
123}
124
125// Is the given path already in the files list.
126//
127// path - String
128//
129// Returns a boolean.
130List.prototype._exists = function (path) {
131 var self = this
132
133 var patterns = this._patterns.filter(function (pattern) {
134 return mm(path, pattern.pattern)
135 })
136
137 return !!_.find(patterns, function (pattern) {
138 return self._findFile(path, pattern)
139 })
140}
141
142// Check if we are currently refreshing
143List.prototype._isRefreshing = function () {
144 return this._refreshing.isPending()
145}
146
147// Do the actual work of refreshing
148List.prototype._refresh = function () {
149 var self = this
150 var buckets = this.buckets
151 var matchedFiles = new Set()
152
153 var promise = Promise.map(this._patterns, function (patternObject) {
154 var pattern = patternObject.pattern
155
156 if (helper.isUrlAbsolute(pattern)) {
157 buckets.set(pattern, new Set([new Url(pattern)]))
158 return Promise.resolve()
159 }
160
161 var mg = new Glob(pathLib.normalize(pattern), GLOB_OPTS)
162 var files = mg.found
163 buckets.set(pattern, new Set())
164
165 if (_.isEmpty(files)) {
166 log.warn('Pattern "%s" does not match any file.', pattern)
167 return
168 }
169
170 return Promise.map(files, function (path) {
171 if (self._isExcluded(path)) {
172 log.debug('Excluded file "%s"', path)
173 return Promise.resolve()
174 }
175
176 if (matchedFiles.has(path)) {
177 return Promise.resolve()
178 }
179
180 matchedFiles.add(path)
181
182 var mtime = mg.statCache[path].mtime
183 var doNotCache = patternObject.nocache
184 var type = patternObject.type
185 var file = new File(path, mtime, doNotCache, type)
186
187 if (file.doNotCache) {
188 log.debug('Not preprocessing "%s" due to nocache')
189 return Promise.resolve(file)
190 }
191
192 return self._preprocess(file).then(function () {
193 return file
194 })
195 })
196 .then(function (files) {
197 files = _.compact(files)
198
199 if (_.isEmpty(files)) {
200 log.warn('All files matched by "%s" were excluded or matched by prior matchers.', pattern)
201 } else {
202 buckets.set(pattern, new Set(files))
203 }
204 })
205 })
206 .then(function () {
207 if (self._refreshing !== promise) {
208 return self._refreshing
209 }
210 self.buckets = buckets
211 self._emitModified(true)
212 return self.files
213 })
214
215 return promise
216}
217
218// Public Interface
219// ----------------
220
221Object.defineProperty(List.prototype, 'files', {
222 get: function () {
223 var self = this
224 var uniqueFlat = function (list) {
225 return _.uniq(_.flatten(list), 'path')
226 }
227
228 var expandPattern = function (p) {
229 return Array.from(self.buckets.get(p.pattern) || []).sort(byPath)
230 }
231
232 var served = this._patterns.filter(function (pattern) {
233 return pattern.served
234 })
235 .map(expandPattern)
236
237 var lookup = {}
238 var included = {}
239 this._patterns.forEach(function (p) {
240 // This needs to be here sadly, as plugins are modifiying
241 // the _patterns directly resulting in elements not being
242 // instantiated properly
243 if (p.constructor.name !== 'Pattern') {
244 p = createPatternObject(p)
245 }
246
247 var bucket = expandPattern(p)
248 bucket.forEach(function (file) {
249 var other = lookup[file.path]
250 if (other && other.compare(p) < 0) return
251 lookup[file.path] = p
252 if (p.included) {
253 included[file.path] = file
254 } else {
255 delete included[file.path]
256 }
257 })
258 })
259
260 return {
261 served: uniqueFlat(served),
262 included: _.values(included)
263 }
264 }
265})
266
267// Reglob all patterns to update the list.
268//
269// Returns a promise that is resolved when the refresh
270// is completed.
271List.prototype.refresh = function () {
272 this._refreshing = this._refresh()
273 return this._refreshing
274}
275
276// Set new patterns and excludes and update
277// the list accordingly
278//
279// patterns - Array, the new patterns.
280// excludes - Array, the new exclude patterns.
281//
282// Returns a promise that is resolved when the refresh
283// is completed.
284List.prototype.reload = function (patterns, excludes) {
285 this._patterns = patterns
286 this._excludes = excludes
287
288 // Wait until the current refresh is done and then do a
289 // refresh to ensure a refresh actually happens
290 return this.refresh()
291}
292
293// Add a new file from the list.
294// This is called by the watcher
295//
296// path - String, the path of the file to update.
297//
298// Returns a promise that is resolved when the update
299// is completed.
300List.prototype.addFile = function (path) {
301 var self = this
302
303 // Ensure we are not adding a file that should be excluded
304 var excluded = this._isExcluded(path)
305 if (excluded) {
306 log.debug('Add file "%s" ignored. Excluded by "%s".', path, excluded)
307
308 return Promise.resolve(this.files)
309 }
310
311 var pattern = this._isIncluded(path)
312
313 if (!pattern) {
314 log.debug('Add file "%s" ignored. Does not match any pattern.', path)
315 return Promise.resolve(this.files)
316 }
317
318 if (this._exists(path)) {
319 log.debug('Add file "%s" ignored. Already in the list.', path)
320 return Promise.resolve(this.files)
321 }
322
323 var file = new File(path)
324 this.buckets.get(pattern.pattern).add(file)
325
326 return Promise.all([
327 fs.statAsync(path),
328 this._refreshing
329 ]).spread(function (stat) {
330 file.mtime = stat.mtime
331 return self._preprocess(file)
332 })
333 .then(function () {
334 log.info('Added file "%s".', path)
335 self._emitModified()
336 return self.files
337 })
338}
339
340// Update the `mtime` of a file.
341// This is called by the watcher
342//
343// path - String, the path of the file to update.
344//
345// Returns a promise that is resolved when the update
346// is completed.
347List.prototype.changeFile = function (path) {
348 var self = this
349
350 var pattern = this._isIncluded(path)
351 var file = this._findFile(path, pattern)
352
353 if (!pattern || !file) {
354 log.debug('Changed file "%s" ignored. Does not match any file in the list.', path)
355 return Promise.resolve(this.files)
356 }
357
358 return Promise.all([
359 fs.statAsync(path),
360 this._refreshing
361 ]).spread(function (stat) {
362 if (stat.mtime <= file.mtime) throw new Promise.CancellationError()
363
364 file.mtime = stat.mtime
365 return self._preprocess(file)
366 })
367 .then(function () {
368 log.info('Changed file "%s".', path)
369 self._emitModified()
370 return self.files
371 })
372 .catch(Promise.CancellationError, function () {
373 return self.files
374 })
375}
376
377// Remove a file from the list.
378// This is called by the watcher
379//
380// path - String, the path of the file to update.
381//
382// Returns a promise that is resolved when the update
383// is completed.
384List.prototype.removeFile = function (path) {
385 var self = this
386
387 return Promise.try(function () {
388 var pattern = self._isIncluded(path)
389 var file = self._findFile(path, pattern)
390
391 if (!pattern || !file) {
392 log.debug('Removed file "%s" ignored. Does not match any file in the list.', path)
393 return self.files
394 }
395
396 self.buckets.get(pattern.pattern).delete(file)
397
398 log.info('Removed file "%s".', path)
399 self._emitModified()
400 return self.files
401 })
402}
403
404// Inject dependencies
405List.$inject = ['config.files', 'config.exclude', 'emitter', 'preprocess',
406 'config.autoWatchBatchDelay']
407
408// PUBLIC
409module.exports = List