file-list.js 10 KB

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