karma
Version:
Spectacular Test Runner for JavaScript.
404 lines (340 loc) • 10 kB
JavaScript
// File List
// =========
//
// The List is an object for tracking all files that karma knows about
// currently.
// Dependencies
// ------------
require('core-js')
var from = require('core-js/library/fn/array/from')
var Promise = require('bluebird')
var mm = require('minimatch')
var Glob = require('glob').Glob
var fs = Promise.promisifyAll(require('graceful-fs'))
var pathLib = require('path')
var _ = require('lodash')
var File = require('./file')
var Url = require('./url')
var helper = require('./helper')
var log = require('./logger').create('watcher')
var createPatternObject = require('./config').createPatternObject
// Constants
// ---------
var GLOB_OPTS = {
cwd: '/',
follow: true,
nodir: true,
sync: true
}
// Helper Functions
// ----------------
function byPath (a, b) {
if (a.path > b.path) return 1
if (a.path < b.path) return -1
return 0
}
// Constructor
//
// patterns - Array
// excludes - Array
// emitter - EventEmitter
// preprocess - Function
// batchInterval - Number
var List = function (patterns, excludes, emitter, preprocess, batchInterval) {
// Store options
this._patterns = patterns
this._excludes = excludes
this._emitter = emitter
this._preprocess = Promise.promisify(preprocess)
this._batchInterval = batchInterval
// The actual list of files
this.buckets = new Map()
// Internal tracker if we are refreshing.
// When a refresh is triggered this gets set
// to the promise that `this._refresh` returns.
// So we know we are refreshing when this promise
// is still pending, and we are done when it's either
// resolved or rejected.
this._refreshing = Promise.resolve()
var self = this
// Emit the `file_list_modified` event.
// This function is throttled to the value of `batchInterval`
// to avoid spamming the listener.
function emit () {
self._emitter.emit('file_list_modified', self.files)
}
var throttledEmit = _.throttle(emit, self._batchInterval, {leading: false})
self._emitModified = function (immediate) {
immediate ? emit() : throttledEmit()
}
}
// Private Interface
// -----------------
// Is the given path matched by any exclusion filter
//
// path - String
//
// Returns `undefined` if no match, otherwise the matching
// pattern.
List.prototype._isExcluded = function (path) {
return _.find(this._excludes, function (pattern) {
return mm(path, pattern)
})
}
// Find the matching include pattern for the given path.
//
// path - String
//
// Returns the match or `undefined` if none found.
List.prototype._isIncluded = function (path) {
return _.find(this._patterns, function (pattern) {
return mm(path, pattern.pattern)
})
}
// Find the given path in the bucket corresponding
// to the given pattern.
//
// path - String
// pattern - Object
//
// Returns a File or undefined
List.prototype._findFile = function (path, pattern) {
if (!path || !pattern) return
if (!this.buckets.has(pattern.pattern)) return
return _.find(from(this.buckets.get(pattern.pattern)), function (file) {
return file.originalPath === path
})
}
// Is the given path already in the files list.
//
// path - String
//
// Returns a boolean.
List.prototype._exists = function (path) {
var self = this
var patterns = this._patterns.filter(function (pattern) {
return mm(path, pattern.pattern)
})
return !!_.find(patterns, function (pattern) {
return self._findFile(path, pattern)
})
}
// Check if we are currently refreshing
List.prototype._isRefreshing = function () {
return this._refreshing.isPending()
}
// Do the actual work of refreshing
List.prototype._refresh = function () {
var self = this
var buckets = this.buckets
var promise = Promise.map(this._patterns, function (patternObject) {
var pattern = patternObject.pattern
if (helper.isUrlAbsolute(pattern)) {
buckets.set(pattern, new Set([new Url(pattern)]))
return Promise.resolve()
}
var mg = new Glob(pathLib.normalize(pattern), GLOB_OPTS)
var files = mg.found
buckets.set(pattern, new Set())
if (_.isEmpty(files)) {
log.warn('Pattern "%s" does not match any file.', pattern)
return
}
return Promise.map(files, function (path) {
if (self._isExcluded(path)) {
log.debug('Excluded file "%s"', path)
return Promise.resolve()
}
var mtime = mg.statCache[path].mtime
var doNotCache = patternObject.nocache
var file = new File(path, mtime, doNotCache)
if (file.doNotCache) {
log.debug('Not preprocessing "%s" due to nocache')
return Promise.resolve(file)
}
return self._preprocess(file).then(function () {
return file
})
})
.then(function (files) {
files = _.compact(files)
if (_.isEmpty(files)) {
log.warn('All files matched by "%s" were excluded.', pattern)
} else {
buckets.set(pattern, new Set(files))
}
})
})
.then(function () {
if (self._refreshing !== promise) {
return self._refreshing
}
self.buckets = buckets
self._emitModified(true)
return self.files
})
return promise
}
// Public Interface
// ----------------
Object.defineProperty(List.prototype, 'files', {
get: function () {
var self = this
var uniqueFlat = function (list) {
return _.uniq(_.flatten(list), 'path')
}
var expandPattern = function (p) {
return from(self.buckets.get(p.pattern) || []).sort(byPath)
}
var served = this._patterns.filter(function (pattern) {
return pattern.served
})
.map(expandPattern)
var lookup = {}
var included = {}
this._patterns.forEach(function (p) {
// This needs to be here sadly, as plugins are modifiying
// the _patterns directly resulting in elements not being
// instantiated properly
if (p.constructor.name !== 'Pattern') {
p = createPatternObject(p)
}
var bucket = expandPattern(p)
bucket.forEach(function (file) {
var other = lookup[file.path]
if (other && other.compare(p) < 0) return
lookup[file.path] = p
if (p.included) {
included[file.path] = file
} else {
delete included[file.path]
}
})
})
return {
served: uniqueFlat(served),
included: _.values(included)
}
}
})
// Reglob all patterns to update the list.
//
// Returns a promise that is resolved when the refresh
// is completed.
List.prototype.refresh = function () {
this._refreshing = this._refresh()
return this._refreshing
}
// Set new patterns and excludes and update
// the list accordingly
//
// patterns - Array, the new patterns.
// excludes - Array, the new exclude patterns.
//
// Returns a promise that is resolved when the refresh
// is completed.
List.prototype.reload = function (patterns, excludes) {
this._patterns = patterns
this._excludes = excludes
// Wait until the current refresh is done and then do a
// refresh to ensure a refresh actually happens
return this.refresh()
}
// Add a new file from the list.
// This is called by the watcher
//
// path - String, the path of the file to update.
//
// Returns a promise that is resolved when the update
// is completed.
List.prototype.addFile = function (path) {
var self = this
// Ensure we are not adding a file that should be excluded
var excluded = this._isExcluded(path)
if (excluded) {
log.debug('Add file "%s" ignored. Excluded by "%s".', path, excluded)
return Promise.resolve(this.files)
}
var pattern = this._isIncluded(path)
if (!pattern) {
log.debug('Add file "%s" ignored. Does not match any pattern.', path)
return Promise.resolve(this.files)
}
if (this._exists(path)) {
log.debug('Add file "%s" ignored. Already in the list.', path)
return Promise.resolve(this.files)
}
var file = new File(path)
this.buckets.get(pattern.pattern).add(file)
return Promise.all([
fs.statAsync(path),
this._refreshing
]).spread(function (stat) {
file.mtime = stat.mtime
return self._preprocess(file)
})
.then(function () {
log.info('Added file "%s".', path)
self._emitModified()
return self.files
})
}
// Update the `mtime` of a file.
// This is called by the watcher
//
// path - String, the path of the file to update.
//
// Returns a promise that is resolved when the update
// is completed.
List.prototype.changeFile = function (path) {
var self = this
var pattern = this._isIncluded(path)
var file = this._findFile(path, pattern)
if (!pattern || !file) {
log.debug('Changed file "%s" ignored. Does not match any file in the list.', path)
return Promise.resolve(this.files)
}
return Promise.all([
fs.statAsync(path),
this._refreshing
]).spread(function (stat) {
if (stat.mtime <= file.mtime) throw new Promise.CancellationError()
file.mtime = stat.mtime
return self._preprocess(file)
})
.then(function () {
log.info('Changed file "%s".', path)
self._emitModified()
return self.files
})
.catch(Promise.CancellationError, function () {
return self.files
})
}
// Remove a file from the list.
// This is called by the watcher
//
// path - String, the path of the file to update.
//
// Returns a promise that is resolved when the update
// is completed.
List.prototype.removeFile = function (path) {
var self = this
return Promise.try(function () {
var pattern = self._isIncluded(path)
var file = self._findFile(path, pattern)
if (!pattern || !file) {
log.debug('Removed file "%s" ignored. Does not match any file in the list.', path)
return self.files
}
self.buckets.get(pattern.pattern).delete(file)
log.info('Removed file "%s".', path)
self._emitModified()
return self.files
})
}
// Inject dependencies
List.$inject = ['config.files', 'config.exclude', 'emitter', 'preprocess',
'config.autoWatchBatchDelay']
// PUBLIC
module.exports = List