├── .gitignore ├── test ├── buster.js ├── helper.js ├── fs-watcher-test.js ├── change-tracker-test.js ├── fs-filtered-test.js ├── walk-tree-test.js ├── tree-watcher-test.js ├── watch-tree-unix-test.js └── os-watch-helper.js ├── lib ├── fs-watch-tree.js ├── async.js ├── watch-tree-generic.js ├── walk-tree.js ├── change-tracker.js ├── fs-watcher.js ├── fs-filtered.js ├── watch-tree-unix.js └── tree-watcher.js ├── package.json ├── check-fs-watch-results ├── osx.txt ├── centos.txt ├── ubuntu.txt └── windows.txt ├── check-fs-watch.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /test/buster.js: -------------------------------------------------------------------------------- 1 | exports["watch tree tests"] = { 2 | rootPath: "../", 3 | environment: "node", 4 | tests: ["test/**/*-test.js"] 5 | }; 6 | -------------------------------------------------------------------------------- /lib/fs-watch-tree.js: -------------------------------------------------------------------------------- 1 | module.exports = process.platform === "linux" || process.platform === "darwin" ? 2 | require("./watch-tree-unix") : 3 | require("./watch-tree-generic"); 4 | -------------------------------------------------------------------------------- /lib/async.js: -------------------------------------------------------------------------------- 1 | function map(func, list, callback) { 2 | if (list.length === 0) { 3 | return callback(null, []); 4 | } 5 | var results = [], remaining = list.length; 6 | list.forEach(function (elem, index) { 7 | func(elem, function (err, data) { 8 | if (err && !callback.called) { 9 | callback.called = true; 10 | callback(err); 11 | } else { 12 | results[index] = data; 13 | remaining -= 1; 14 | if (remaining === 0) { 15 | callback(null, results); 16 | } 17 | } 18 | }); 19 | }); 20 | } 21 | 22 | module.exports = { map: map }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fs-watch-tree", 3 | "version": "0.2.0", 4 | "description": "Recursively watch directories for changes", 5 | "homepage": "http://busterjs.org/doc/fs-watch-tree", 6 | "author": { 7 | "name": "Christian Johansen", 8 | "email": "christian@cjohansen.no", 9 | "url": "http://cjohansen.no" 10 | }, 11 | "contributors": [{ 12 | "name": "August Lilleaas", 13 | "email": "august.lilleaas@gmail.com", 14 | "url": "http://augustl.com" 15 | }, { 16 | "name": "Magnar Sveen", 17 | "email": "magnars@gmail.com" 18 | }], 19 | "main": "./lib/fs-watch-tree", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/busterjs/fs-watch-tree.git" 23 | }, 24 | "dependencies": { 25 | "when": "https://github.com/cujojs/when/tarball/1.0.2", 26 | "minimatch": "~0.2" 27 | }, 28 | "devDependencies": { 29 | "rimraf": "*" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /check-fs-watch-results/osx.txt: -------------------------------------------------------------------------------- 1 | OSX 2 | ====================================== 3 | 4 | Changing file 5 | -------------------------------------- 6 | event on file.txt: change null 7 | 8 | Creating file 9 | -------------------------------------- 10 | event on dir1: rename null 11 | 12 | Deleting file 13 | -------------------------------------- 14 | event on dir1: rename null 15 | event on file.txt: rename null 16 | 17 | Renaming file 18 | -------------------------------------- 19 | event on file.txt: rename null 20 | event on dir1: rename null 21 | 22 | Moving file 23 | -------------------------------------- 24 | event on file.txt: rename null 25 | event on dir2: rename null 26 | event on dir1: rename null 27 | 28 | Creating dir 29 | -------------------------------------- 30 | event on dir1: rename null 31 | 32 | Renaming dir 33 | -------------------------------------- 34 | event on dir1: rename null 35 | event on root: rename null 36 | 37 | Moving dir 38 | -------------------------------------- 39 | event on dir1: rename null 40 | event on dir2: rename null 41 | event on root: rename null 42 | 43 | Deleting dir 44 | -------------------------------------- 45 | event on root: rename null 46 | -------------------------------------- 47 | -------------------------------------------------------------------------------- /lib/watch-tree-generic.js: -------------------------------------------------------------------------------- 1 | var treeWatcher = require("./tree-watcher"); 2 | 3 | function t() { return true; }; 4 | function f() { return false; }; 5 | 6 | var defaultEvent = { 7 | isMkdir: f, 8 | isDelete: f, 9 | isModify: f 10 | }; 11 | 12 | function watchTree(dir, options, callback) { 13 | if (arguments.length == 2 && typeof options == "function") { 14 | callback = options; 15 | options = {}; 16 | } 17 | options = options || {}; 18 | 19 | var watcher = treeWatcher.create(dir, options.exclude || []); 20 | 21 | var eventHandler = function (type) { 22 | return function (file) { 23 | var e = Object.create(defaultEvent); 24 | e.isDirectory = file.isDirectory() ? t : f; 25 | e.name = file.name; 26 | e[type] = t; 27 | callback(e); 28 | }; 29 | }; 30 | 31 | watcher.on("dir:create", eventHandler("isMkdir")); 32 | watcher.on("dir:delete", eventHandler("isDelete")); 33 | watcher.on("file:delete", eventHandler("isDelete")); 34 | watcher.on("file:change", eventHandler("isModify")); 35 | watcher.on("file:create", eventHandler("isModify")); 36 | 37 | watcher.init(); 38 | 39 | return { 40 | end: function () { watcher.end(); } 41 | }; 42 | } 43 | 44 | module.exports = { 45 | watchTree: watchTree 46 | }; -------------------------------------------------------------------------------- /check-fs-watch-results/centos.txt: -------------------------------------------------------------------------------- 1 | CENTOS 2 | ====================================== 3 | 4 | Changing file 5 | -------------------------------------- 6 | event on dir1: change file.txt 7 | event on file.txt: change file.txt 8 | 9 | Creating file 10 | -------------------------------------- 11 | event on dir1: rename file2.txt 12 | event on dir1: change file2.txt 13 | 14 | Deleting file 15 | -------------------------------------- 16 | event on dir1: rename file.txt 17 | event on file.txt: change file.txt 18 | event on file.txt: rename file.txt 19 | event on file.txt: rename file.txt 20 | 21 | Renaming file 22 | -------------------------------------- 23 | event on dir1: rename file.txt 24 | event on dir1: rename new.txt 25 | 26 | Moving file 27 | -------------------------------------- 28 | event on dir1: rename file.txt 29 | event on dir2: rename file.txt 30 | 31 | Creating dir 32 | -------------------------------------- 33 | event on dir1: rename dir11 34 | 35 | Deleting dir 36 | -------------------------------------- 37 | event on root: rename dir2 38 | event on dir2: rename dir2 39 | event on dir2: rename dir2 40 | 41 | Renaming dir 42 | -------------------------------------- 43 | event on root: rename dir1 44 | event on root: rename awesomedir 45 | 46 | Moving dir 47 | -------------------------------------- 48 | event on root: rename dir1 49 | event on dir2: rename dir1 50 | -------------------------------------- 51 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | var when = require("when"); 2 | var path = require("path"); 3 | var fs = require("fs"); 4 | 5 | module.exports = { 6 | ROOT: path.join(__dirname, ".#fixtures"), 7 | 8 | mktreeSync: function mktreeSync(tree, root) { 9 | root = root || module.exports.ROOT; 10 | var file; 11 | 12 | for (var prop in tree) { 13 | file = path.join(root, prop); 14 | 15 | if (typeof tree[prop] == "object") { 16 | fs.mkdirSync(file, "0755"); 17 | mktreeSync(tree[prop], file); 18 | } else { 19 | fs.writeFileSync(file, tree[prop], "utf-8"); 20 | } 21 | } 22 | 23 | return module.exports.ROOT; 24 | }, 25 | 26 | mktree: function mktree(tree, root) { 27 | root = root || module.exports.ROOT; 28 | var file, d; 29 | var promises = []; 30 | 31 | for (var prop in tree) { 32 | d = when.defer(); 33 | file = path.join(root, prop); 34 | 35 | promises.push(d.promise); 36 | 37 | if (typeof tree[prop] == "object") { 38 | fs.mkdir(file, "0755", function (prop, file, d) { 39 | mktree(tree[prop], file).then(d.resolve); 40 | }.bind(this, prop, file, d)); 41 | } else { 42 | fs.writeFile(file, tree[prop], "utf-8", d.resolve); 43 | } 44 | } 45 | 46 | return when.all(promises); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /check-fs-watch-results/ubuntu.txt: -------------------------------------------------------------------------------- 1 | UBUNTU 2 | ====================================== 3 | 4 | Changing file 5 | -------------------------------------- 6 | event on file.txt: change file.txt 7 | event on file.txt: change file.txt 8 | event on dir1: change file.txt 9 | event on dir1: change file.txt 10 | 11 | Creating file 12 | -------------------------------------- 13 | event on dir1: rename file2.txt 14 | event on dir1: change file2.txt 15 | 16 | Deleting file 17 | -------------------------------------- 18 | event on dir1: rename file.txt 19 | event on file.txt: change file.txt 20 | event on file.txt: rename file.txt 21 | event on file.txt: rename file.txt 22 | 23 | Renaming file 24 | -------------------------------------- 25 | event on dir1: rename file.txt 26 | event on dir1: rename new.txt 27 | 28 | Moving file 29 | -------------------------------------- 30 | event on dir2: rename file.txt 31 | event on dir1: rename file.txt 32 | 33 | Creating dir 34 | -------------------------------------- 35 | event on dir1: rename dir11 36 | 37 | Deleting dir 38 | -------------------------------------- 39 | event on dir2: rename dir2 40 | event on dir2: rename dir2 41 | event on root: rename dir2 42 | 43 | Renaming dir 44 | -------------------------------------- 45 | event on root: rename dir1 46 | event on root: rename awesomedir 47 | 48 | Moving dir 49 | -------------------------------------- 50 | event on dir2: rename dir1 51 | event on root: rename dir1 52 | -------------------------------------- 53 | -------------------------------------------------------------------------------- /lib/walk-tree.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var path = require("path"); 3 | 4 | function isExcluded(file, excludes) { 5 | for (var i = 0, l = excludes.length; i < l; ++i) { 6 | if (excludes[i].test(file)) { 7 | return true; 8 | } 9 | } 10 | 11 | return false; 12 | } 13 | 14 | function excludeRegExes(excludes) { 15 | if (!excludes) return []; 16 | 17 | return excludes.map(function (exclude) { 18 | if (typeof exclude == "string") { 19 | return new RegExp(exclude); 20 | } else { 21 | return exclude; 22 | } 23 | }); 24 | } 25 | 26 | function fileProcessor(directory, excludes, callback) { 27 | return function (item) { 28 | var file = path.join(directory, item); 29 | 30 | fs.stat(file, function (err, stat) { 31 | if (err) return callback(err); 32 | if (!stat.isDirectory() || isExcluded(file, excludes)) return; 33 | callback(null, file); 34 | walkTreeWithExcludes(file, excludes, callback); 35 | }); 36 | }; 37 | } 38 | 39 | function walkTreeWithExcludes(directory, excludes, callback) { 40 | fs.readdir(directory, function (err, items) { 41 | if (err) return callback(err); 42 | items.forEach(fileProcessor(directory, excludes, callback)); 43 | }); 44 | } 45 | 46 | module.exports = { 47 | walkTree: function walkTree(directory, options, callback) { 48 | if (arguments.length == 2 && typeof options == "function") { 49 | callback = options; 50 | options = null; 51 | } 52 | 53 | var excludes = excludeRegExes(options && options.exclude); 54 | walkTreeWithExcludes(directory, excludes, callback); 55 | } 56 | }; 57 | 58 | module.exports.excludeRegExes = excludeRegExes; 59 | module.exports.isExcluded = isExcluded; 60 | -------------------------------------------------------------------------------- /check-fs-watch-results/windows.txt: -------------------------------------------------------------------------------- 1 | WINDOWS 2 | ====================================== 3 | 4 | Changing file 5 | -------------------------------------- 6 | event on dir1: change file.txt 7 | event on file.txt: change file.txt 8 | event on dir1: change file.txt 9 | event on file.txt: change file.txt 10 | 11 | Creating file 12 | -------------------------------------- 13 | event on root: change dir1 14 | event on dir1: rename file2.txt 15 | event on root: change dir1 16 | event on dir1: change file2.txt 17 | 18 | Deleting file 19 | -------------------------------------- 20 | event on dir1: rename null 21 | event on file.txt: rename file.txt 22 | 23 | Renaming file 24 | -------------------------------------- 25 | event on dir1: rename null 26 | event on dir1: rename new.txt 27 | event on file.txt: rename file.txt 28 | event on root: change dir1 29 | event on dir1: change new.txt 30 | 31 | Moving file 32 | -------------------------------------- 33 | event on dir1: rename null 34 | event on file.txt: rename file.txt 35 | event on dir2: rename file.txt 36 | event on root: change dir2 37 | event on dir2: change file.txt 38 | 39 | Creating dir 40 | -------------------------------------- 41 | event on dir1: rename dir11 42 | event on root: change dir1 43 | 44 | Renaming dir 45 | -------------------------------------- 46 | event on root: rename null 47 | event on root: rename awesomedir 48 | event on root: change awesomedir 49 | 50 | Moving dir 51 | -------------------------------------- 52 | event on root: rename null 53 | event on dir2: rename dir1 54 | event on root: change dir2 55 | event on dir2: change dir1 56 | 57 | Deleting dir 58 | -------------------------------------- 59 | 60 | events.js:48 61 | throw arguments[1]; // Unhandled 'error' event 62 | ^ 63 | Error: watch EPERM 64 | at errnoException (fs.js:644:11) 65 | at FSEvent.onchange (fs.js:658:26) 66 | -------------------------------------------------------------------------------- /test/fs-watcher-test.js: -------------------------------------------------------------------------------- 1 | var buster = require("buster"); 2 | var fs = require("fs"); 3 | 4 | var fsWatcher = require("../lib/fs-watcher"); 5 | 6 | buster.testCase('fs-watcher', { 7 | setUp: function () { 8 | this.closer = { close: this.spy() }; 9 | this.stub(fs, "watch").returns(this.closer); 10 | this.watcher = fsWatcher.create(); 11 | }, 12 | 13 | "watches files": function () { 14 | this.watcher.watch({ name: "file.txt" }, this.spy()); 15 | assert.calledOnceWith(fs.watch, "file.txt"); 16 | }, 17 | 18 | "calls back when file changes": function () { 19 | var spy = this.spy(); 20 | var file = { name: "file.txt" }; 21 | this.watcher.watch(file, spy); 22 | 23 | fs.watch.yield("change"); 24 | 25 | assert.calledOnceWith(spy, "change", file); 26 | }, 27 | 28 | "unwatches files": function () { 29 | this.watcher.watch({ name: "file.txt" }, this.spy()); 30 | this.watcher.unwatch({ name: "file.txt" }); 31 | 32 | assert.calledOnce(this.closer.close); 33 | }, 34 | 35 | "unwatches directories": function () { 36 | this.watcher.fileSeparator = "/"; 37 | 38 | this.watcher.watch({ name: "files" }, this.spy()); 39 | this.watcher.watch({ name: "files/file1.txt" }, this.spy()); 40 | this.watcher.watch({ name: "files/file2.txt" }, this.spy()); 41 | this.watcher.watch({ name: "filesystem.txt" }, this.spy()); 42 | this.watcher.watch({ name: "notes/file1.txt" }, this.spy()); 43 | 44 | this.watcher.unwatchDir({ name: "files" }); 45 | 46 | assert.calledThrice(this.closer.close); 47 | }, 48 | 49 | "closes watches": function () { 50 | this.watcher.watch({ name: "file1.txt" }, this.spy()); 51 | this.watcher.watch({ name: "file2.txt" }, this.spy()); 52 | this.watcher.end(); 53 | 54 | assert.calledTwice(this.closer.close); 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /lib/change-tracker.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Keeps track of items being created or deleted in a list 3 | * - emits events about changes when .poll is called 4 | * - events are: delete, create 5 | * 6 | * Usage: 7 | * 8 | * var tracker = changeTracker.create(updateItems, items); 9 | * 10 | * - updateItems is a function to fetch the current state of the items you want 11 | * to watch. It should return a list of objects with a unique 'name'. 12 | * 13 | * - items is the current list, as given by running updateItems now 14 | * 15 | * tracker.on("create", createListener); 16 | * tracker.on("delete", deleteListener); 17 | * tracker.poll(); 18 | * 19 | * When calling poll, updateItems is called, the result is compared to the old 20 | * list, and events are emitted. 21 | * 22 | */ 23 | 24 | var EventEmitter = require("events").EventEmitter; 25 | var when = require("when"); 26 | 27 | function create(updateItems, items) { 28 | var instance = Object.create(this); 29 | instance.updateItems = updateItems; 30 | instance.items = items; 31 | return instance; 32 | } 33 | 34 | function eq(item1) { 35 | return function (item2) { return item1.name === item2.name; }; 36 | } 37 | 38 | function notIn(coll) { 39 | return function (item) { return !coll.some(eq(item)); }; 40 | } 41 | 42 | function poll() { 43 | var d = when.defer(); 44 | 45 | this.updateItems(function (err, after) { 46 | if (err) { return d.reject(err); } 47 | 48 | var before = this.items; 49 | 50 | var created = after.filter(notIn(before)); 51 | var deleted = before.filter(notIn(after)); 52 | 53 | created.forEach(this.emit.bind(this, "create")); 54 | deleted.forEach(this.emit.bind(this, "delete")); 55 | 56 | this.items = after; 57 | 58 | d.resolve(); 59 | }.bind(this)); 60 | 61 | return d.promise; 62 | } 63 | 64 | module.exports = new EventEmitter(); 65 | module.exports.create = create; 66 | module.exports.poll = poll; 67 | -------------------------------------------------------------------------------- /lib/fs-watcher.js: -------------------------------------------------------------------------------- 1 | /* 2 | * - keeps track of watched files 3 | * - simplifies closing all watchers 4 | * - simplifies closing some watchers, like all those under a directory 5 | */ 6 | 7 | var fs = require("fs"); 8 | 9 | var isWindows = process.platform === 'win32'; // the path library does not 10 | var fileSeparator = isWindows ? "\\" : "/"; // expose the fileSeparator :-o 11 | 12 | function close(o) { o.closer.close(); } 13 | 14 | function forEachWatcher(fn) { 15 | for (var key in this.watchers) { 16 | if (this.watchers.hasOwnProperty(key)) { 17 | fn(this.watchers[key]); 18 | } 19 | } 20 | } 21 | 22 | function isInDir(file, dir) { 23 | var dirPath = dir.name + this.fileSeparator; 24 | return file.name.substring(0, dirPath.length) === dirPath; 25 | } 26 | 27 | module.exports = { 28 | create: function () { 29 | var instance = Object.create(this); 30 | instance.watchers = {}; 31 | instance.fileSeparator = fileSeparator; 32 | return instance; 33 | }, 34 | 35 | watch: function (file, callback) { 36 | if (this.watchers[file.name]) { 37 | throw new Error("Already watching " + file.name); 38 | } 39 | 40 | var closer = fs.watch(file.name, function (event) { 41 | return callback(event, file); 42 | }); 43 | 44 | this.watchers[file.name] = { 45 | file: file, 46 | closer: closer 47 | }; 48 | }, 49 | 50 | unwatch: function (file) { 51 | close(this.watchers[file.name]); 52 | delete this.watchers[file.name]; 53 | }, 54 | 55 | unwatchDir: function (dir) { 56 | this.unwatch(dir); 57 | forEachWatcher.call(this, function (watcher) { 58 | var file = watcher.file; 59 | if (isInDir.call(this, file, dir)) { this.unwatch(file); } 60 | }.bind(this)); 61 | }, 62 | 63 | end: function () { 64 | forEachWatcher.call(this, close); 65 | } 66 | }; -------------------------------------------------------------------------------- /lib/fs-filtered.js: -------------------------------------------------------------------------------- 1 | /* fs-filtered finds files in a dir, stats them all, and adds full path names. 2 | * It takes a set of exclusions that it uses to filter out files and dirs. 3 | * 4 | * Usage no filters: 5 | * 6 | * fsFiltered.statFiles(dir, function (files) {}); 7 | * 8 | * With filters: 9 | * 10 | * var f = fsFiltered.create(["node_modules", "vendor"]); 11 | * f.statFiles(dir, function (files) {}); 12 | * 13 | */ 14 | 15 | var fs = require("fs"); 16 | var path = require("path"); 17 | var async = require("./async"); 18 | var minimatch = require("minimatch"); 19 | 20 | function statFile(file, callback) { 21 | fs.stat(file, function (err, stats) { 22 | if (err) return callback(err); 23 | stats.name = file; 24 | callback(null, stats); 25 | }); 26 | } 27 | 28 | function fullPath(dir) { 29 | return function (file) { return path.join(dir, file); }; 30 | } 31 | 32 | function exclude(patterns) { 33 | return function (path) { 34 | return patterns.every(function (pattern) { return !path.match(pattern); }); 35 | }; 36 | } 37 | 38 | function include(patterns) { 39 | return function (file) { 40 | return patterns.some(function (pattern) { 41 | return file.isDirectory() || file.name.match(pattern); 42 | }); 43 | }; 44 | } 45 | 46 | function statFiles(dir, callback) { 47 | var excludes = this.excludes || []; 48 | var includes = this.includes || []; 49 | 50 | fs.readdir(dir, function (err, names) { 51 | if (err) return callback(err); 52 | 53 | names = names.map(fullPath(dir)).filter(exclude(excludes)); 54 | 55 | async.map(statFile, names, function (err, files) { 56 | if (err) { return callback(err); } 57 | 58 | if (includes.length) { 59 | files = files.filter(include(includes)); 60 | } 61 | callback(null, files); 62 | }); 63 | }); 64 | } 65 | 66 | function regexpify(o) { 67 | return typeof o === "string" ? minimatch.makeRe(o) : o; 68 | } 69 | 70 | function create(params) { 71 | return Object.create(this, { 72 | excludes: { value: params.exclude }, 73 | includes: { value: (params.include || []).map(regexpify) } 74 | }); 75 | } 76 | 77 | module.exports.statFile = statFile; 78 | module.exports.statFiles = statFiles; 79 | module.exports.create = create; 80 | -------------------------------------------------------------------------------- /test/change-tracker-test.js: -------------------------------------------------------------------------------- 1 | var buster = require("buster"); 2 | var changeTracker = require("../lib/change-tracker"); 3 | 4 | buster.testCase("change-tracker", { 5 | "poll": { 6 | setUp: function () { 7 | this.statFiles = this.stub(); 8 | this.tracker = changeTracker.create(this.statFiles, [ 9 | { name: "stale" }, 10 | { name: "fresh" } 11 | ]); 12 | }, 13 | 14 | "resolves promise after statting successfully": function () { 15 | var spy = this.spy(); 16 | this.tracker.poll().then(spy); 17 | 18 | refute.called(spy); 19 | 20 | this.statFiles.yield(null, []); 21 | assert.called(spy); 22 | }, 23 | 24 | "rejects promise on error": function () { 25 | var success = this.spy(); 26 | var failure = this.spy(); 27 | this.tracker.poll().then(success, failure); 28 | 29 | this.statFiles.yield("Gosh darn it!"); 30 | refute.called(success); 31 | assert.calledOnceWith(failure, "Gosh darn it!"); 32 | }, 33 | 34 | "emits delete for files that are missing": function () { 35 | var listener = this.spy(); 36 | this.tracker.on("delete", listener); 37 | 38 | this.statFiles.yields(null, [ 39 | { name: "stale" } 40 | ]); 41 | this.tracker.poll(); 42 | 43 | assert.calledOnceWith(listener, { name: "fresh" }); 44 | }, 45 | 46 | "emits create for files that are brand new": function () { 47 | var listener = this.spy(); 48 | this.tracker.on("create", listener); 49 | 50 | this.statFiles.yields(null, [ 51 | { name: "stale" }, 52 | { name: "fresh" }, 53 | { name: "spanking" } 54 | ]); 55 | this.tracker.poll(); 56 | 57 | assert.calledOnceWith(listener, { name: "spanking" }); 58 | }, 59 | 60 | "keeps track of changes": function () { 61 | var listener = this.spy(); 62 | this.tracker.on("delete", listener); 63 | 64 | this.statFiles.yields(null, [ 65 | { name: "stale" } 66 | ]); 67 | this.tracker.poll(); 68 | this.tracker.poll(); 69 | 70 | assert.calledOnce(listener); 71 | } 72 | } 73 | }); 74 | -------------------------------------------------------------------------------- /check-fs-watch.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var rmrf = require("rimraf"); 3 | 4 | function setup(done) { 5 | fs.mkdir("root", function () { 6 | fs.mkdir("root/dir1", function () { 7 | fs.mkdir("root/dir2", function () { 8 | fs.writeFile("root/dir1/file.txt", "", done); 9 | }); 10 | }); 11 | }); 12 | } 13 | 14 | function test(params, done) { 15 | setup(function () { 16 | var w1 = fs.watch("root", function (a, b) { console.log(" event on root:", a, b); }); 17 | var w2 = fs.watch("root/dir1", function (a, b) { console.log(" event on dir1:", a, b); }); 18 | var w3 = fs.watch("root/dir2", function (a, b) { console.log(" event on dir2:", a, b); }); 19 | var w4 = fs.watch("root/dir1/file.txt", function (a, b) { console.log(" event on file.txt:", a, b); }); 20 | 21 | console.log(""); 22 | console.log(params.header); 23 | console.log("--------------------------------------"); 24 | params.action(function () { 25 | setTimeout(function () { 26 | w1.close(); 27 | w2.close(); 28 | w3.close(); 29 | w4.close(); 30 | rmrf("root", done); 31 | }, 100); 32 | }); 33 | }); 34 | } 35 | 36 | var tests = [ 37 | { header: "Changing file", action: function (done) { fs.writeFile("root/dir1/file.txt", "1", done); } }, 38 | { header: "Creating file", action: function (done) { fs.writeFile("root/dir1/file2.txt", "1", done); } }, 39 | { header: "Deleting file", action: function (done) { fs.unlink("root/dir1/file.txt", done); } }, 40 | { header: "Renaming file", action: function (done) { fs.rename("root/dir1/file.txt", "root/dir1/new.txt", done); } }, 41 | { header: "Moving file", action: function (done) { fs.rename("root/dir1/file.txt", "root/dir2/file.txt", done); } }, 42 | { header: "Creating dir", action: function (done) { fs.mkdir("root/dir1/dir11", done); } }, 43 | { header: "Deleting dir", action: function (done) { rmrf("root/dir2", done); } }, 44 | { header: "Renaming dir", action: function (done) { fs.rename("root/dir1", "root/awesomedir", done); } }, 45 | { header: "Moving dir", action: function (done) { fs.rename("root/dir1", "root/dir2/dir1", done); } } 46 | ]; 47 | 48 | function nextTest() { 49 | if (tests.length > 0) { 50 | test(tests.shift(), nextTest); 51 | } else { 52 | console.log("--------------------------------------"); 53 | } 54 | } 55 | 56 | nextTest(); 57 | -------------------------------------------------------------------------------- /test/fs-filtered-test.js: -------------------------------------------------------------------------------- 1 | var buster = require("buster"); 2 | var fs = require("fs"); 3 | 4 | var fsFiltered = require("../lib/fs-filtered"); 5 | 6 | var p = require("path").join; 7 | 8 | function t() { return true; } 9 | function f() { return false; } 10 | 11 | buster.testCase('fs-filtered', { 12 | setUp: function () { 13 | this.stub(fs, "readdir").yields(null, ["mydir", "f1.js", "f2.md", ".#meh"]); 14 | this.stub(fs, "stat"); 15 | fs.stat.withArgs(p("tmp", "mydir")).yields(null, { stats: "oh", isDirectory: t }); 16 | fs.stat.withArgs(p("tmp", "f1.js")).yields(null, { stats: "yo", isDirectory: f }); 17 | fs.stat.withArgs(p("tmp", "f2.md")).yields(null, { stats: "ho", isDirectory: f }); 18 | fs.stat.withArgs(p("tmp", ".#meh")).yields(null, { stats: "no", isDirectory: f }); 19 | }, 20 | 21 | "stats all files in a directory": function () { 22 | var callback = this.spy(); 23 | fsFiltered.statFiles("tmp", callback); 24 | 25 | assert.calledWith(fs.readdir, "tmp"); 26 | assert.calledOnceWith(callback, null, [ 27 | { name: p("tmp", "mydir"), stats: "oh", isDirectory: t }, 28 | { name: p("tmp", "f1.js"), stats: "yo", isDirectory: f }, 29 | { name: p("tmp", "f2.md"), stats: "ho", isDirectory: f }, 30 | { name: p("tmp", ".#meh"), stats: "no", isDirectory: f } 31 | ]); 32 | }, 33 | 34 | "filters out unwanted files by regexp": function () { 35 | var callback = this.spy(); 36 | fsFiltered.create({ exclude: ["#"] }).statFiles("tmp", callback); 37 | 38 | assert.calledOnceWith(callback, null, [ 39 | { name: p("tmp", "mydir"), stats: "oh", isDirectory: t }, 40 | { name: p("tmp", "f1.js"), stats: "yo", isDirectory: f }, 41 | { name: p("tmp", "f2.md"), stats: "ho", isDirectory: f } 42 | ]); 43 | }, 44 | 45 | "filter in wanted files by regexp": function () { 46 | var callback = this.spy(); 47 | fsFiltered.create({ include: [/\.js$/] }).statFiles("tmp", callback); 48 | 49 | assert.calledOnceWith(callback, null, [ 50 | { name: p("tmp", "mydir"), stats: "oh", isDirectory: t }, 51 | { name: p("tmp", "f1.js"), stats: "yo", isDirectory: f } 52 | ]); 53 | }, 54 | 55 | "filter in wanted files by glob": function () { 56 | var callback = this.spy(); 57 | fsFiltered.create({ include: ["tmp/*.md"] }).statFiles("tmp", callback); 58 | 59 | assert.calledOnceWith(callback, null, [ 60 | { name: p("tmp", "mydir"), stats: "oh", isDirectory: t }, 61 | { name: p("tmp", "f2.md"), stats: "ho", isDirectory: f } 62 | ]); 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /lib/watch-tree-unix.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var fs = require("fs"); 3 | var wt = require("./walk-tree"); 4 | 5 | function createEvent(dirs, event, dir, fileName) { 6 | var fullPath = path.join(dir, fileName); 7 | var exists = dirs.some(function (d) { return d === fullPath; }); 8 | var statObj; 9 | 10 | function stat() { 11 | if (statObj) { return statObj; } 12 | if (!fullPath) { 13 | statObj = { isDirectory: function () { return false; } }; 14 | } else { 15 | try { 16 | statObj = fs.statSync(fullPath); 17 | } catch (e) { 18 | statObj = { 19 | isDirectory: function () { return false; }, 20 | deleted: true 21 | }; 22 | } 23 | } 24 | return statObj; 25 | } 26 | 27 | return { 28 | name: fullPath, 29 | 30 | isDirectory: function () { 31 | return stat().isDirectory(); 32 | }, 33 | 34 | isMkdir: function () { 35 | return this.isDirectory() && !exists; 36 | }, 37 | 38 | isDelete: function () { 39 | return !!stat().deleted; 40 | }, 41 | 42 | isModify: function () { 43 | return !this.isDelete() && !this.isMkdir(); 44 | } 45 | }; 46 | } 47 | 48 | function watch(state, dir, options, callback) { 49 | return fs.watch(dir, function (event, fileName) { 50 | var e = createEvent(state.dirs, event, dir, fileName); 51 | 52 | if (e.isDirectory() && e.isMkdir()) { 53 | addWatch(state, e.name, options, callback); 54 | } 55 | 56 | if (!wt.isExcluded(e.name, options.exclude) && 57 | typeof callback == "function") { 58 | callback(e); 59 | } 60 | }); 61 | } 62 | 63 | function addWatch(state, dir, options, callback) { 64 | state.dirs = state.dirs || []; 65 | state.dirs.push(dir); 66 | state.watches = state.watches || []; 67 | state.watches.push(watch(state, dir, options, callback)); 68 | } 69 | 70 | function watchTree(dir, options, callback) { 71 | if (arguments.length == 2 && typeof options == "function") { 72 | callback = options; 73 | options = {}; 74 | } 75 | 76 | var state = {}; 77 | options = options || {}; 78 | options.exclude = wt.excludeRegExes(options.exclude); 79 | 80 | addWatch(state, dir, options, callback); 81 | wt.walkTree(dir, options, function (err, dir) { 82 | if (err) return; 83 | addWatch(state, dir, options, callback); 84 | }); 85 | 86 | return { 87 | end: function () { 88 | state.watches.forEach(function (w) { w.close(); }); 89 | } 90 | }; 91 | } 92 | 93 | module.exports = { 94 | watchTree: watchTree 95 | }; 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fs-watch-tree - Recursive fs.watch # 2 | 3 | **fs-watch-tree** is a small tool to watch directories for changes recursively. 4 | It uses 5 | [fs-watch](http://nodejs.org/docs/latest/api/fs.html#fs_fs_watch_filename_options_listener) 6 | to watch for changes, thus should work on most platforms. 7 | 8 | ## Synopsis ## 9 | 10 | var watchTree = require("fs-watch-tree").watchTree; 11 | 12 | var watch = watchTree("/home/christian", function (event) { 13 | // See description of event below 14 | }); 15 | 16 | watch.end(); // Release watch 17 | 18 | watch = watchTree("/home/christian", { 19 | exclude: ["node_modules", "~", "#", /^\./] 20 | }, function (event) { 21 | // Respond to change 22 | }); 23 | 24 | ## `watchTree(dir, callback)` ## 25 | 26 | Watches directory `dir` recursively for changes. 27 | 28 | The callback is called with an `event` object. The event is described below. 29 | 30 | ## `watchTree(dir, options, callback)` ## 31 | 32 | Watch a directory recursively, with some specific options. Currently, you can 33 | only specify a single option: 34 | 35 | { exclude: [] } 36 | 37 | The `exclude` array specifies file patterns to exclude from watches. If a 38 | pattern matches a directory, `watch-tree` will not recurse into it. If it 39 | matches a file, changes to that file will not trigger an event. 40 | 41 | The excludes can be either strings or regular expressions, but are always 42 | treated as regular expressions. That means that 43 | 44 | { exclude: [".git", "node_modules"] } 45 | 46 | Will be treated the same way as: 47 | 48 | { exclude: [new RegExp(".git"), new RegExp("node_modules")] } 49 | 50 | If you only want to exclude specific files, be sure to provide full 51 | paths. `watch-tree` does not expand paths, it will resolve all paths relative to 52 | the original directory. So this: 53 | 54 | watchFile(".git", function (event) { /* ... *) }); 55 | 56 | Will watch (and consider excludes for) directories like `.git/branches`. And 57 | this: 58 | 59 | watchFile("/home/christian/projects/watch-tree/.git", function (event) {}); 60 | 61 | Will watch (and consider excludes for) directories like 62 | `/home/christian/projects/watch-tree/.git`. 63 | 64 | ## `event` ## 65 | 66 | The event object has the following properties: 67 | 68 | ### `name` ### 69 | 70 | The full (relative) path to the file/directory that changed. 71 | 72 | ### `isDirectory()` ### 73 | 74 | Returns true if the cause of the change was a directory. In some cases, 75 | e.g. when the directory was deleted, it's not possible to know if the 76 | source was a directory. In that case, this method returns false. 77 | 78 | ### `isMkdir()` ### 79 | 80 | Returns true if the cause of the event was a newly created directory. 81 | 82 | ### `isDelete()` ### 83 | 84 | Returns true if the cause of the event was a deleted file/directory. 85 | 86 | ### `isModify()` ### 87 | 88 | Returns true if the cause of the event was a modified file/directory. 89 | -------------------------------------------------------------------------------- /test/walk-tree-test.js: -------------------------------------------------------------------------------- 1 | var buster = require("buster"); 2 | var rmrf = require("rimraf"); 3 | var path = require("path"); 4 | var fs = require("fs"); 5 | var fsu = require("../lib/walk-tree"); 6 | var helper = require("./helper"); 7 | 8 | function walkTreeTest(options) { 9 | return function (done) { 10 | var root = helper.mktreeSync(options.tree); 11 | 12 | var callback = this.spy(function () { 13 | if (callback.callCount != options.expected.length) return; 14 | setTimeout(verify, 10); 15 | }); 16 | 17 | function verify() { 18 | assert.equals(callback.callCount, options.expected.length); 19 | 20 | options.expected.forEach(function (dir) { 21 | assert.calledWith(callback, null, root + dir); 22 | }); 23 | 24 | done(); 25 | } 26 | 27 | if (options.exclude) { 28 | fsu.walkTree(root, { exclude: options.exclude }, callback); 29 | } else { 30 | fsu.walkTree(root, callback); 31 | } 32 | }; 33 | } 34 | 35 | buster.testCase("walk-tree", { 36 | setUp: function () { 37 | fs.mkdirSync(helper.ROOT, "0755"); 38 | }, 39 | 40 | tearDown: function (done) { 41 | rmrf(helper.ROOT, done); 42 | }, 43 | 44 | "yields all directories to callback": walkTreeTest({ 45 | expected: ["/projects", "/documents", "/music"], 46 | tree: { 47 | projects: {}, 48 | documents: {}, 49 | music: {} 50 | } 51 | }), 52 | 53 | "should not yield files to callback": walkTreeTest({ 54 | expected: ["/projects", "/documents"], 55 | tree: { 56 | projects: {}, 57 | music: "This is a text file", 58 | documents: {} 59 | } 60 | }), 61 | 62 | "should not yield excluded directories to callback": walkTreeTest({ 63 | expected: ["/projects", "/documents"], 64 | exclude: [helper.ROOT + "/music"], 65 | tree: { 66 | projects: {}, 67 | music: {}, 68 | documents: {} 69 | } 70 | }), 71 | 72 | "should not yield excluded directories by regexp to callback": walkTreeTest({ 73 | expected: ["/projects", "/documents"], 74 | exclude: ["music"], 75 | tree: { 76 | projects: {}, 77 | music: {}, 78 | documents: {} 79 | } 80 | }), 81 | 82 | "should yield recursive directories to callback": walkTreeTest({ 83 | expected: ["/a", "/a/a1", "/a/a2", "/a/a2/a21", "/a/a2/a22", 84 | "/b", "/b/b3", "/b/b4", "/b/b4/b41", "/b/b4/b41/b411"], 85 | tree: { 86 | a: { a1: {}, a2: { a21: {}, a22: {}, a23: "" } }, 87 | b: { b1: "", b2: "", b3: {}, b4: { b41: { b411: {} } } } 88 | } 89 | }), 90 | 91 | "should not recurse into excluded directories": walkTreeTest({ 92 | expected: ["/a", "/a/a1", "/a/a2", "/a/a2/a22", 93 | "/b", "/b/b3"], 94 | exclude: ["b4", "a21"], 95 | tree: { 96 | a: { a1: {}, a2: { a21: {}, a22: {}, a23: "" } }, 97 | b: { b1: "", b2: "", b3: {}, b4: { b41: { b411: {} } } } 98 | } 99 | }), 100 | 101 | "yields readdir error to callback": function () { 102 | this.stub(fs, "readdir"); 103 | var callback = this.spy(); 104 | 105 | fsu.walkTree(helper.ROOT, callback); 106 | fs.readdir.args[0][1]({ message: "Oops" }); 107 | 108 | assert.calledWith(callback, { message: "Oops" }); 109 | }, 110 | 111 | "yields stat error to callback": function (done) { 112 | var root = helper.mktreeSync({ "a": {} }); 113 | this.stub(fs, "stat").yields({ message: "Oops" }); 114 | 115 | var callback = this.spy(function (err) { 116 | assert.equals(err, { message: "Oops" }); 117 | done(); 118 | }); 119 | 120 | fsu.walkTree(root, callback); 121 | } 122 | }); 123 | -------------------------------------------------------------------------------- /lib/tree-watcher.js: -------------------------------------------------------------------------------- 1 | /* 2 | * - watches all files in a directory for events 3 | * 4 | * Usage: 5 | * 6 | * var watcher = treeWatcher.create(dir, ignoredPatterns); 7 | * 8 | * watcher.on("file:change", handler); 9 | * watcher.on("file:delete", handler); 10 | * watcher.on("file:create", handler); 11 | * watcher.on("dir:change", handler); 12 | * watcher.on("dir:delete", handler); 13 | * watcher.on("dir:create", handler); 14 | * 15 | * watcher.init(); 16 | * 17 | * dir is the path to the directory 18 | * ignoredPatterns is an array of strings/regexes to ignore 19 | * 20 | * init() returns a promise for when all the watchers are ready 21 | * 22 | * You can also stop the watching with: 23 | * 24 | * watcher.end(); 25 | * 26 | */ 27 | 28 | var fs = require("fs"); 29 | var when = require("when"); 30 | var EventEmitter = require("events").EventEmitter; 31 | var changeTracker = require("./change-tracker"); 32 | 33 | function notDirectory(file) { 34 | return !file.isDirectory(); 35 | } 36 | 37 | function throttle(fn, threshold) { 38 | var last = 0; 39 | return function () { 40 | var now = new Date().getTime(); 41 | if (now - last >= threshold) { 42 | last = now; 43 | return fn.apply(this, arguments); 44 | } 45 | }; 46 | } 47 | 48 | function partial(fn, arg) { 49 | return fn.bind(null, arg); // yes, it's dumbed down 50 | } 51 | 52 | function create(root, excludes) { 53 | var emitter = new EventEmitter(); 54 | var watcher = require("./fs-watcher").create(); 55 | var fsFiltered = require("./fs-filtered").create({ exclude: excludes }); 56 | 57 | function emit(event, file) { 58 | var type = file.isDirectory() ? "dir" : "file"; 59 | emitter.emit(type + ":" + event, file); 60 | } 61 | 62 | var emitCreate = partial(emit, "create"); 63 | var emitDelete = partial(emit, "delete"); 64 | 65 | function watchDir(dir) { 66 | var d = when.defer(); 67 | var statFiles = fsFiltered.statFiles.bind(fsFiltered, dir.name); 68 | 69 | statFiles(function (err, files) { 70 | if (err) { return d.reject(err); } 71 | 72 | var tracker = changeTracker.create(statFiles, files); 73 | 74 | tracker.on("create", watch); 75 | tracker.on("delete", unwatch); 76 | 77 | tracker.on("create", emitCreate); 78 | tracker.on("delete", emitDelete); 79 | 80 | watcher.watch(dir, function () { return tracker.poll(); }); 81 | 82 | when.all(files.map(watch)).then(d.resolve); 83 | }); 84 | 85 | return d.promise; 86 | } 87 | 88 | function emitChange(file, event) { 89 | if (event === "change") { 90 | emitter.emit("file:change", file); 91 | }; 92 | } 93 | 94 | // throttle file:change since Windows fires a couple events per actual 95 | // change. 10 ms seems enough to catch the duplicates 96 | function watchFile(file) { 97 | watcher.watch(file, throttle(partial(emitChange, file), 10)); 98 | return when(true); 99 | } 100 | 101 | function watch(file) { 102 | if (file.isDirectory()) { 103 | return watchDir(file); 104 | } else { 105 | return watchFile(file); 106 | } 107 | } 108 | 109 | function unwatch(file) { 110 | if (file.isDirectory()) { 111 | watcher.unwatchDir(file); 112 | } else { 113 | watcher.unwatch(file); 114 | } 115 | } 116 | 117 | function init() { 118 | var d = when.defer(); 119 | 120 | fsFiltered.statFile(root, function (err, file) { 121 | if (err) { return d.reject(err); } 122 | 123 | watchDir(file).then(d.resolve); 124 | }); 125 | 126 | return d.promise; 127 | } 128 | 129 | function end() { 130 | watcher.end(); 131 | } 132 | 133 | emitter.init = init; 134 | emitter.end = end; 135 | 136 | return emitter; 137 | } 138 | 139 | module.exports = { create: create }; 140 | -------------------------------------------------------------------------------- /test/tree-watcher-test.js: -------------------------------------------------------------------------------- 1 | var buster = require("buster"); 2 | var fs = require("fs"); 3 | var rmrf = require("rimraf"); 4 | var path = require("path"); 5 | 6 | var helper = require("./helper"); 7 | var osWatch = require("./os-watch-helper"); 8 | 9 | var treeWatcher = require("../lib/tree-watcher"); 10 | 11 | function p() { 12 | var names = [helper.ROOT].concat([].slice.call(arguments)); 13 | return path.join.apply(path, names); 14 | } 15 | 16 | function eventTest(options) { 17 | return function (done) { 18 | var spy = this.spy(); 19 | this.watcher.on(options.event, spy); 20 | 21 | options.action.call(this).then(done(function () { 22 | assert.calledOnce(spy); 23 | })); 24 | }; 25 | } 26 | 27 | function createTree(callback) { 28 | fs.mkdir(helper.ROOT, "0755", function () { 29 | helper.mktree({ 30 | subdir: { "nested.txt": "", "ignored.txt": "" }, 31 | deleteme: {}, 32 | ignored: {}, 33 | "exists.txt": "" 34 | }).then(callback); 35 | }); 36 | } 37 | 38 | function testPlatform(platform) { 39 | buster.testCase('tree-watcher-' + platform, { 40 | setUp: function (done) { 41 | this.timeout = 1000; 42 | createTree(function () { 43 | this.os = osWatch.on(this, platform); 44 | this.watcher = treeWatcher.create(helper.ROOT, [/ignored/]); 45 | this.watcher.init().then(done); 46 | }.bind(this)); 47 | }, 48 | 49 | tearDown: function (done) { 50 | rmrf(helper.ROOT, done); 51 | }, 52 | 53 | "end closes all the watches": function () { 54 | this.watcher.end(); 55 | assert.equals(this.os.watchers.length, 0); 56 | }, 57 | 58 | "emits 'file:change'": eventTest({ 59 | event: "file:change", 60 | action: function () { 61 | return this.os.change(p("exists.txt")); 62 | } 63 | }), 64 | 65 | "emits 'file:change' for nested files": eventTest({ 66 | event: "file:change", 67 | action: function () { 68 | return this.os.change(p("subdir", "nested.txt")); 69 | } 70 | }), 71 | 72 | "emits 'file:create'": eventTest({ 73 | event: "file:create", 74 | action: function () { 75 | return this.os.create(p("spanking-new.txt")); 76 | } 77 | }), 78 | 79 | "emits 'file:delete'": eventTest({ 80 | event: "file:delete", 81 | action: function () { 82 | return this.os.rm(p("exists.txt")); 83 | } 84 | }), 85 | 86 | "emits 'dir:create'": eventTest({ 87 | event: "dir:create", 88 | action: function () { 89 | return this.os.mkdir(p("newone")); 90 | } 91 | }), 92 | 93 | "emits 'dir:delete'": eventTest({ 94 | event: "dir:delete", 95 | action: function () { 96 | return this.os.rmdir(p("deleteme")); 97 | } 98 | }), 99 | 100 | "ignores files": function (done) { 101 | var spy = this.spy(); 102 | this.watcher.on("file:change", spy); 103 | 104 | this.os.change(p("subdir", "ignored.txt")).then(done(function () { 105 | refute.called(spy); 106 | })); 107 | }, 108 | 109 | "ignores directories": function (done) { 110 | var spy = this.spy(); 111 | this.watcher.on("file:create", spy); 112 | 113 | this.os.create(p("ignored", "file.txt")).then(done(function () { 114 | refute.called(spy); 115 | })); 116 | }, 117 | 118 | "watches new files": function (done) { 119 | this.os.create(p("newfile.txt")).then(function () { 120 | var spy = this.spy(); 121 | this.watcher.on("file:change", spy); 122 | this.os.change(p("newfile.txt")).then(done(function () { 123 | assert.calledOnce(spy); 124 | })); 125 | }.bind(this)); 126 | } 127 | }); 128 | } 129 | 130 | ["unix", "osx", "windows"].forEach(testPlatform); 131 | 132 | // testPlatform("integration"); 133 | -------------------------------------------------------------------------------- /test/watch-tree-unix-test.js: -------------------------------------------------------------------------------- 1 | var buster = require("buster"); 2 | var watchTree = require("../lib/watch-tree-unix").watchTree; 3 | var walkTree = require("../lib/walk-tree"); 4 | var helper = require("./helper"); 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | var rmrf = require("rimraf"); 8 | 9 | function p(filePath) { 10 | return path.resolve(helper.ROOT, filePath); 11 | } 12 | 13 | function assertWatched(spy, path) { 14 | for (var i = 0, l = spy.callCount; i < l; ++i) { 15 | if (spy.getCall(i).args[0] == path) { 16 | buster.assertions.emit("pass"); 17 | return true; 18 | } 19 | } 20 | 21 | var e = new Error("Expected " + path + " to be watched, but wasn't\n" + 22 | spy.printf("%C")); 23 | e.name = "AssertionError"; 24 | buster.assertions.emit("failure", e); 25 | } 26 | 27 | function watchTest(options) { 28 | return function (done) { 29 | var self = this; 30 | 31 | this.onWatch = function () { 32 | if (fs.watch.callCount == this.expectedCount) { 33 | setTimeout(function () { 34 | options.assert.call(self); 35 | done(); 36 | }, 10); 37 | } 38 | }; 39 | 40 | options.act.call(this); 41 | }; 42 | } 43 | 44 | function eventTest(options) { 45 | return watchTest({ 46 | act: function () { 47 | this.callback = this.spy(); 48 | 49 | if (options.act) { 50 | options.act.call(this); 51 | } else { 52 | watchTree(helper.ROOT, this.callback); 53 | } 54 | }, 55 | 56 | assert: function () { 57 | var ev = options && options.event || {}; 58 | fs.watch.args[0][1](ev.type, ev.file); 59 | options.assert.call(this); 60 | } 61 | }); 62 | } 63 | 64 | buster.testCase("watch-tree-unix", { 65 | setUp: function () { 66 | fs.mkdirSync(helper.ROOT, "0755"); 67 | 68 | helper.mktreeSync({ 69 | "a-dir": { a1: {}, a2: { a21: {}, a22: {}, a23: "" } }, 70 | "b-dir": { b1: "", b2: "", b3: {}, b4: { b41: { b411: {} } } } 71 | }); 72 | 73 | var self = this; 74 | this.onWatch = function () {}; 75 | this.expectedCount = 11; 76 | this.watcher = buster.eventEmitter.create(); 77 | this.watcher.close = this.stub(); 78 | this.stub(fs, "watch", function () { 79 | self.onWatch.apply(self, arguments); 80 | return self.watcher; 81 | }); 82 | }, 83 | 84 | tearDown: function (done) { 85 | rmrf(helper.ROOT, done); 86 | }, 87 | 88 | "walks tree": function () { 89 | this.stub(walkTree, "walkTree"); 90 | 91 | watchTree("/home/christian"); 92 | 93 | assert.calledOnce(walkTree.walkTree); 94 | assert.calledWith(walkTree.walkTree, "/home/christian"); 95 | }, 96 | 97 | "watches each directory": watchTest({ 98 | act: function () { 99 | watchTree(helper.ROOT); 100 | }, 101 | 102 | assert: function () { 103 | assert.equals(fs.watch.callCount, 11); 104 | assertWatched(fs.watch, helper.ROOT); 105 | assertWatched(fs.watch, p("a-dir")); 106 | assertWatched(fs.watch, p("a-dir/a1")); 107 | assertWatched(fs.watch, p("a-dir/a2")); 108 | assertWatched(fs.watch, p("a-dir/a2/a21")); 109 | assertWatched(fs.watch, p("a-dir/a2/a22")); 110 | assertWatched(fs.watch, p("b-dir")); 111 | assertWatched(fs.watch, p("b-dir/b3")); 112 | assertWatched(fs.watch, p("b-dir/b4")); 113 | assertWatched(fs.watch, p("b-dir/b4/b41")); 114 | assertWatched(fs.watch, p("b-dir/b4/b41/b411")); 115 | } 116 | }), 117 | 118 | "returns endable object": watchTest({ 119 | act: function () { 120 | this.watch = watchTree(helper.ROOT); 121 | }, 122 | 123 | assert: function () { 124 | this.watch.end(); 125 | assert.equals(this.watcher.close.callCount, 11); 126 | } 127 | }), 128 | 129 | "should not watch excluded directory": watchTest({ 130 | act: function () { 131 | this.expectedCount = 6; 132 | watchTree(helper.ROOT, { exclude: ["b-dir"] }); 133 | }, 134 | 135 | assert: function () { 136 | assert.equals(fs.watch.callCount, 6); 137 | assertWatched(fs.watch, helper.ROOT); 138 | assertWatched(fs.watch, p("a-dir")); 139 | assertWatched(fs.watch, p("a-dir/a1")); 140 | assertWatched(fs.watch, p("a-dir/a2")); 141 | assertWatched(fs.watch, p("a-dir/a2/a21")); 142 | assertWatched(fs.watch, p("a-dir/a2/a22")); 143 | } 144 | }), 145 | 146 | "should not exclude directories without options": watchTest({ 147 | act: function () { 148 | watchTree(helper.ROOT, function () {}); 149 | }, 150 | 151 | assert: function () { 152 | assert.equals(fs.watch.callCount, 11); 153 | } 154 | }), 155 | 156 | "calls callback with event": eventTest({ 157 | event: { type: "change", file: "buster.js" }, 158 | 159 | assert: function () { 160 | assert.calledOnce(this.callback); 161 | var event = this.callback.args[0][0]; 162 | assert.match(event, { name: path.join(helper.ROOT, "buster.js") }); 163 | assert(!event.isMkdir()); 164 | refute(event.isDirectory()); 165 | } 166 | }), 167 | 168 | "calls callback with directory event": eventTest({ 169 | event: { type: "change", file: "a-dir" }, 170 | 171 | assert: function () { 172 | var event = this.callback.args[0][0]; 173 | assert(event.isDirectory()); 174 | } 175 | }), 176 | 177 | "calls callback with mkdir event": eventTest({ 178 | event: { type: "change", file: "c" }, 179 | 180 | act: function () { 181 | watchTree(helper.ROOT, this.callback); 182 | helper.mktreeSync({ c: {} }); 183 | }, 184 | 185 | assert: function () { 186 | assert(this.callback.args[0][0].isMkdir()); 187 | } 188 | }), 189 | 190 | "should not call callback with excluded file": eventTest({ 191 | act: function () { 192 | watchTree(helper.ROOT, { exclude: ["~"] }, this.callback); 193 | }, 194 | 195 | event: { type: "change", file: "~buster.js" }, 196 | 197 | assert: function () { 198 | refute.called(this.callback); 199 | } 200 | }), 201 | 202 | "automatically watches new diretories": watchTest({ 203 | act: function () { 204 | watchTree(helper.ROOT); 205 | }, 206 | 207 | assert: function () { 208 | var callCount = fs.watch.callCount; 209 | helper.mktreeSync({ newone: {} }); 210 | fs.watch.args[0][1]("change", "newone"); 211 | 212 | assert.equals(fs.watch.callCount, callCount + 1); 213 | assertWatched(fs.watch, fs.watch.args[0][0] + "/newone"); 214 | } 215 | }) 216 | }); 217 | -------------------------------------------------------------------------------- /test/os-watch-helper.js: -------------------------------------------------------------------------------- 1 | /* 2 | * os-watch stubs out and simulates the behavior of fs.watch on different 3 | * platforms. 4 | * 5 | * Usage: 6 | * 7 | * var os = osWatch.on(this, "windows"); // `this` is the test context 8 | * 9 | * os.create("file") 10 | * os.change("file") 11 | * os.mkdir("dir") 12 | * os.rmdir("dir") 13 | * os.rm("file") 14 | * os.rename("file", "new") // todo 15 | * os.move("file", "dir") // todo 16 | * 17 | * Platforms: 18 | * 19 | * unix 20 | * windows // todo 21 | * osx 22 | * integration (no stubbing) 23 | * 24 | */ 25 | 26 | var fs = require("fs"); 27 | var path = require("path"); 28 | var when = require("when"); 29 | 30 | var base = path.basename; 31 | var dir = path.dirname; 32 | 33 | var unique = new Date().getTime(); 34 | var noop = function () {}; 35 | 36 | var is = function (file) { return function (watcher) { return file === watcher.file; }; }; 37 | 38 | var notEq = function (watcher) { return function (w) { return w !== watcher; }; }; 39 | 40 | function removeWatcher(watcher) { 41 | this.watchers = this.watchers.filter(notEq(watcher)); 42 | } 43 | 44 | function watch (file, callback) { 45 | var watcher = { 46 | file: file, 47 | callback: callback 48 | }; 49 | this.watchers.push(watcher); 50 | return { close: removeWatcher.bind(this, watcher) }; 51 | } 52 | 53 | function event(file, event, info) { 54 | return when.all(this.watchers.filter(is(file)).map(function (watcher) { 55 | return watcher.callback(event, info); 56 | })); 57 | } 58 | 59 | function wait() { 60 | var d = when.defer(); 61 | setTimeout(d.resolve, 300); 62 | return d.promise; 63 | } 64 | 65 | function setUp(context) { 66 | this.watchers = []; 67 | context.stub(fs, "watch", watch.bind(this)); 68 | } 69 | 70 | var platforms = { 71 | "integration": { 72 | setUp: function () { 73 | this.watchers = []; 74 | // counting watchers is unsupported in integration tests 75 | // but this makes sure that the "end" test runs after all :-P 76 | }, 77 | change: wait, 78 | create: wait, 79 | rm: wait, 80 | mkdir: wait, 81 | rmdir: wait 82 | }, 83 | 84 | "osx": { 85 | setUp: setUp, 86 | 87 | change: function (file) { 88 | return event.call(this, file, "change", null); 89 | }, 90 | 91 | create: function (file) { 92 | return event.call(this, dir(file), "rename", null); 93 | }, 94 | 95 | rm: function (file) { 96 | return when.all([ 97 | event.call(this, dir(file), "rename", null), 98 | event.call(this, file, "rename", null) 99 | ]); 100 | }, 101 | 102 | mkdir: function (file) { 103 | return event.call(this, dir(file), "rename", null); 104 | }, 105 | 106 | rmdir: function (file) { 107 | return when.all([ 108 | event.call(this, dir(file), "rename", null), 109 | event.call(this, file, "rename", null) 110 | ]); 111 | } 112 | }, 113 | 114 | "unix": { 115 | setUp: setUp, 116 | 117 | change: function (file) { 118 | return when.all([ 119 | event.call(this, dir(file), "change", base(file)), 120 | event.call(this, file, "change", base(file)) 121 | ]); 122 | }, 123 | 124 | create: function (file) { 125 | return when.all([ 126 | event.call(this, dir(file), "rename", base(file)), 127 | event.call(this, dir(file), "change", base(file)) 128 | ]); 129 | }, 130 | 131 | rm: function (file) { 132 | return when.all([ 133 | event.call(this, dir(file), "rename", base(file)), 134 | event.call(this, file, "change", base(file)), 135 | event.call(this, file, "rename", base(file)), 136 | event.call(this, file, "rename", base(file)) 137 | ]); 138 | }, 139 | 140 | mkdir: function (file) { 141 | return event.call(this, dir(file), "rename", base(file)); 142 | }, 143 | 144 | rmdir: function (file) { 145 | return when.all([ 146 | event.call(this, dir(file), "rename", base(file)), 147 | event.call(this, file, "rename", base(file)), 148 | event.call(this, file, "rename", base(file)) 149 | ]); 150 | } 151 | }, 152 | 153 | "windows": { 154 | setUp: setUp, 155 | 156 | change: function (file) { 157 | return when.all([ 158 | event.call(this, dir(file), "change", base(file)), 159 | event.call(this, file, "change", base(file)), 160 | event.call(this, dir(file), "change", base(file)), 161 | event.call(this, file, "change", base(file)) 162 | ]); 163 | }, 164 | 165 | create: function (file) { 166 | return when.all([ 167 | event.call(this, dir(dir(file)), "change", base(dir(file))), 168 | event.call(this, dir(file), "rename", base(file)), 169 | event.call(this, dir(dir(file)), "change", base(dir(file))), 170 | event.call(this, dir(file), "change", base(file)) 171 | ]); 172 | }, 173 | 174 | rm: function (file) { 175 | return when.all([ 176 | event.call(this, dir(file), "rename", null), 177 | event.call(this, file, "rename", base(file)) 178 | ]); 179 | }, 180 | 181 | mkdir: function (file) { 182 | return when.all([ 183 | event.call(this, dir(file), "rename", base(file)), 184 | event.call(this, dir(dir(file)), "change", base(dir(file))) 185 | ]); 186 | }, 187 | 188 | rmdir: function (file) { 189 | return when.all([ 190 | event.call(this, dir(file), "rename", null), 191 | event.call(this, file, "rename", base(file)), 192 | event.call(this, dir(dir(file)), "change", base(dir(file))) 193 | ]); 194 | } 195 | } 196 | }; 197 | 198 | 199 | 200 | module.exports = { 201 | on: function (context, platform) { 202 | var instance = Object.create(this); 203 | var os = Object.create(platforms[platform]); 204 | os.setUp(context); 205 | instance.os = os; 206 | return instance; 207 | }, 208 | 209 | get watchers() { return this.os.watchers; }, 210 | 211 | change: function (file) { 212 | var d = when.defer(), os = this.os; 213 | fs.writeFile(file, unique++, function () { 214 | os.change(file).then(d.resolve, d.resolve); 215 | }); 216 | return d.promise; 217 | }, 218 | 219 | create: function (file) { 220 | var d = when.defer(), os = this.os; 221 | fs.writeFile(file, unique++, function () { 222 | os.create(file).then(d.resolve, d.resolve); 223 | }); 224 | return d.promise; 225 | }, 226 | 227 | rm: function (file) { 228 | var d = when.defer(), os = this.os; 229 | fs.unlink(file, function () { 230 | os.rm(file).then(d.resolve, d.resolve); 231 | }); 232 | return d.promise; 233 | }, 234 | 235 | mkdir: function (file) { 236 | var d = when.defer(), os = this.os; 237 | fs.mkdir(file, function () { 238 | os.mkdir(file).then(d.resolve, d.resolve); 239 | }); 240 | return d.promise; 241 | }, 242 | 243 | rmdir: function (file) { 244 | var d = when.defer(), os = this.os; 245 | fs.rmdir(file, function () { 246 | os.rmdir(file).then(d.resolve, d.resolve); 247 | }); 248 | return d.promise; 249 | } 250 | }; 251 | --------------------------------------------------------------------------------