diff --git a/lib/binding.js b/lib/binding.js index 0f70343c..f1c2bf80 100644 --- a/lib/binding.js +++ b/lib/binding.js @@ -530,7 +530,7 @@ Binding.prototype.open = function(pathname, flags, mode, callback, ctx) { return maybeCallback(normalizeCallback(callback), ctx, this, function() { pathname = deBuffer(pathname); - const descriptor = new FileDescriptor(flags); + const descriptor = new FileDescriptor(flags, pathname); let item = this._system.getItem(pathname); while (item instanceof SymbolicLink) { item = this._system.getItem( @@ -553,6 +553,7 @@ Binding.prototype.open = function(pathname, flags, mode, callback, ctx) { item.setMode(mode); } parent.addItem(path.basename(pathname), item); + parent.notifyRename(pathname); } if (descriptor.isRead()) { if (!item) { @@ -576,6 +577,7 @@ Binding.prototype.open = function(pathname, flags, mode, callback, ctx) { throw new FSError('EBADF'); } item.setContent(''); + item.notifyChange(pathname); } if (descriptor.isTruncate() || descriptor.isAppend()) { descriptor.setPosition(item.getContent().length); @@ -748,6 +750,7 @@ Binding.prototype.writeBuffers = function( const written = newContent.copy(content, position); file.setContent(content); descriptor.setPosition(newLength); + file.notifyChange(descriptor.getPath()); return written; }); }; @@ -805,6 +808,7 @@ Binding.prototype.writeBuffer = function( ); file.setContent(content); descriptor.setPosition(newLength); + file.notifyChange(descriptor.getPath()); return written; }); }; @@ -906,6 +910,9 @@ Binding.prototype.rename = function(oldPath, newPath, callback, ctx) { } oldParent.removeItem(oldName); newParent.addItem(newName, oldItem); + oldItem.notifyRename(newPath); + oldParent.notifyRename(oldPath); + newParent.notifyRename(newPath); }); }; @@ -1021,6 +1028,8 @@ Binding.prototype.mkdir = function(pathname, mode, recursive, callback, ctx) { }.bind(this); _mkdir(pathname); + + // TODO: Should we generate "rename" watch events? If so, for which items? }); }; @@ -1139,6 +1148,7 @@ Binding.prototype.ftruncate = function(fd, len, callback, ctx) { const newContent = bufferAlloc(len); content.copy(newContent); file.setContent(newContent); + file.notifyChange(descriptor.getPath()); }); }; @@ -1352,6 +1362,7 @@ Binding.prototype.link = function(srcPath, destPath, callback, ctx) { throw new FSError('ENOTDIR', destPath); } parent.addItem(path.basename(destPath), item); + parent.notifyRename(destPath); }); }; @@ -1382,6 +1393,7 @@ Binding.prototype.symlink = function(srcPath, destPath, type, callback, ctx) { const link = new SymbolicLink(); link.setPath(srcPath); parent.addItem(path.basename(destPath), link); + parent.notifyRename(destPath); }); }; diff --git a/lib/descriptor.js b/lib/descriptor.js index d8ec0efd..e2bf437a 100644 --- a/lib/descriptor.js +++ b/lib/descriptor.js @@ -5,15 +5,22 @@ const constants = require('constants'); /** * Create a new file descriptor. * @param {number} flags Flags. + * @param {string|undefined} path File path that was used to open this descriptor, if available. * @constructor */ -function FileDescriptor(flags) { +function FileDescriptor(flags, path) { /** * Flags. * @type {number} */ this._flags = flags; + /** + * Path that this descriptor was opened against, if available. + * @type {string|undefined} + */ + this._path = path; + /** * File system item. * @type {Item} @@ -43,6 +50,14 @@ FileDescriptor.prototype.getItem = function() { return this._item; }; +/** + * Get the path that this descriptor was opened against. May be undefined. + * @return {string|undefined} File path. + */ +FileDescriptor.prototype.getPath = function() { + return this._path; +}; + /** * Get the current file position. * @return {number} File position. diff --git a/lib/fsevent.js b/lib/fsevent.js new file mode 100644 index 00000000..2b7d012d --- /dev/null +++ b/lib/fsevent.js @@ -0,0 +1,93 @@ +'use strict'; + +const FSError = require('./error'); + +/** + * Fake binding to replace fs_event_wrap with. + * @param {FileSystem} system Mock file system. + * @constructor + */ +function FSEventWrapBinding(system) { + // Note that _system here is effectively global. I don't see an easier way to do this though. + this.FSEvent._system = system; +} + +/** + * Construct a new FSEvent. + * @constructor + */ +function FSEvent() { + /** + * Item we are watching. + * @type {Item} + */ + this._item = null; + + /** + * Listener function that is currently subscribed (so we can unsubscribe it). + * @type {function} + */ + this._listener = null; + + /** + * Whether we are currently watching. Checked by the FSWatcher implementation. + * @type {boolean} + */ + this.initialized = false; +} + +/** + * Handler for change events for the item we are tracking. + * This will be replaced with a function from Node's internal/fs/watchers.js. + */ +FSEvent.prototype.onchange = function(status, eventType, filename) {}; + +/** + * Refcounting. Ignored because we aren't wrapping a native object. + */ +FSEvent.prototype.ref = function() {}; + +/** + * Refcounting. Ignored because we aren't wrapping a native object. + */ +FSEvent.prototype.unref = function() {}; + +/** + * Start watching a file. This function is called by fs.watch() internally, by way of the + * FSWatcher.prototype[kFSWatchStart] function. + */ +FSEvent.prototype.start = function(filepath, persistent, recursive, encoding) { + if (recursive) { + // TODO: Can we throw the correct Node internal error type here? Does it matter? + throw new FSError('Recursive watch is not supported'); + } + // TODO: Implement persistent? All watchers are currently "persistent" as long as we are mocked + // TODO: Implement encoding='buffer' case + const item = FSEvent._system.getItem(filepath); + if (!item) { + throw new FSError('ENOENT', filepath); + } + if (!item.canRead()) { + throw new FSError('EACCES', filepath); + } + this._item = item; + const onchange = this.onchange; + this._listener = function(eventType, filename) { + // TODO: The stats parameter here is wrong, but fs.watch() doesn't use it + onchange(1, eventType, filename); + }; + this._item.getWatcher().addListener('change', this._listener); + this.initialized = true; +}; + +FSEvent.prototype.close = function() { + this._item.getWatcher().removeListener('change', this._listener); +}; + +FSEventWrapBinding.prototype.FSEvent = FSEvent; + +/** + * Export the FSEventWrapBinding constructor. + * @type {function()} + */ +exports = module.exports = FSEventWrapBinding; diff --git a/lib/index.js b/lib/index.js index eae3868e..d6e9f657 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,8 +2,10 @@ const Binding = require('./binding'); const FSError = require('./error'); +const FSEventWrapBinding = require('./fsevent'); const FileSystem = require('./filesystem'); const realBinding = process.binding('fs'); +const realFSEventWrapBinding = process.binding('fs_event_wrap'); const path = require('path'); const loader = require('./loader'); const bypass = require('./bypass'); @@ -50,10 +52,24 @@ for (const key in Binding.prototype) { } } +// Pre-patch fs_event_wrap binding. +realFSEventWrapBinding.FSEvent = new Proxy(Object, { + construct: function(target, args) { + if (realFSEventWrapBinding._mockedBinding) { + return new realFSEventWrapBinding._mockedBinding.FSEvent(); + } + return new realFSEventWrapBinding.FSEvent(); + } +}); + function overrideBinding(binding) { realBinding._mockedBinding = binding; } +function overrideFSEventWrapBinding(fsEventWrapBinding) { + realFSEventWrapBinding._mockedBinding = fsEventWrapBinding; +} + function overrideProcess(cwd, chdir) { process.cwd = cwd; process.chdir = chdir; @@ -100,6 +116,10 @@ function restoreBinding() { realBinding.StatWatcher = realStatWatcher; } +function restoreFSEventWrapBinding() { + delete realFSEventWrapBinding._mockedBinding; +} + function restoreProcess() { for (const key in realProcessProps) { process[key] = realProcessProps[key]; @@ -122,8 +142,10 @@ function restoreCreateWriteStream() { exports = module.exports = function mock(config, options) { const system = FileSystem.create(config, options); const binding = new Binding(system); + const fsEventWrapBinding = new FSEventWrapBinding(system); overrideBinding(binding); + overrideFSEventWrapBinding(fsEventWrapBinding); let currentPath = process.cwd(); overrideProcess( @@ -165,6 +187,7 @@ exports.getMockRoot = function() { */ exports.restore = function() { restoreBinding(); + restoreFSEventWrapBinding(); restoreProcess(); restoreCreateWriteStream(); }; diff --git a/lib/item.js b/lib/item.js index 1ec3e75d..829e2051 100644 --- a/lib/item.js +++ b/lib/item.js @@ -1,5 +1,7 @@ 'use strict'; +const EventEmitter = require('events'); + let counter = 0; /** @@ -35,6 +37,12 @@ function getGid() { function Item() { const now = Date.now(); + /** + * Emitter for events on this Item. + * @type {EventEmitter} + */ + this._events = new EventEmitter(); + /** * Access time. * @type {Date} @@ -303,6 +311,32 @@ Item.prototype.getStats = function() { }; }; +/** + * Return an EventEmitter for events on this Item. + * @return {EventEmitter} Emitter of events. + */ +Item.prototype.getWatcher = function() { + return this._events; +}; + +/** + * Generate an event that this Item has changed. + * @param {string|undefined} filename The name of the file which triggered the event. + */ +Item.prototype.notifyChange = function(filename) { + const eventType = 'change'; + this._events.emit('change', eventType, filename); +}; + +/** + * Generate an event that this Item (or a contained Item) has been renamed. + * @param {string|undefined} filename The name of the file which triggered the event. + */ +Item.prototype.notifyRename = function(filename) { + const eventType = 'rename'; + this._events.emit('change', eventType, filename); +}; + /** * Get the item's string representation. * @return {string} String representation. diff --git a/test/helper.js b/test/helper.js index a3e0ce34..360d6f8c 100644 --- a/test/helper.js +++ b/test/helper.js @@ -118,3 +118,14 @@ exports.assertEqualPaths = function(actual, expected) { chai.assert(actual, expected); } }; + +exports.assertEvent = function(action, emitter, eventName, ...args) { + const captured = []; + const listener = function() { + captured.push([...arguments]); + }; + emitter.addListener(eventName, listener); + action(); + emitter.removeListener(eventName, listener); + chai.assert.deepEqual([...args], captured[0]); +}; diff --git a/test/lib/descriptor.spec.js b/test/lib/descriptor.spec.js index ef5e8ed5..e480673c 100644 --- a/test/lib/descriptor.spec.js +++ b/test/lib/descriptor.spec.js @@ -15,6 +15,18 @@ describe('FileDescriptor', function() { }); }); + describe('#getPath()', function() { + it('returns undefiend if no path was provided', function() { + const fd = new FileDescriptor(flags('r')); + assert.isUndefined(fd.getPath()); + }); + + it('returns the path the descriptor was opened with', function() { + const fd = new FileDescriptor(flags('r'), '/path/to/file'); + assert.equal(fd.getPath(), '/path/to/file'); + }); + }); + describe('#getPosition()', function() { it('returns zero by default', function() { const fd = new FileDescriptor(flags('r')); diff --git a/test/lib/fs.watch.spec.js b/test/lib/fs.watch.spec.js new file mode 100644 index 00000000..6c7f1734 --- /dev/null +++ b/test/lib/fs.watch.spec.js @@ -0,0 +1,45 @@ +'use strict'; + +const EventEmitter = require('events'); +const helper = require('../helper'); +const fs = require('fs'); +const mock = require('../../lib/index'); + +const assert = helper.assert; +const assertEvent = helper.assertEvent; + +describe('fs.watch(filepath, options, listener)', function() { + beforeEach(function() { + mock({ + 'path/to/file.txt': 'file content' + }); + }); + + afterEach(function() { + mock.restore(); + }); + + it('fails if the path does not exist', function() { + assert.throws(function() { + fs.watch('bogus.txt', {}, function() {}); + }); + }); + + it('returns an instance of EventEmitter', function() { + const watcher = fs.watch('path/to/file.txt'); + assert.instanceOf(watcher, EventEmitter); + }); + + it('emits a change event when the file is written', function() { + const watcher = fs.watch('path/to/file.txt'); + assertEvent( + function() { + fs.writeFileSync('path/to/file.txt', 'new contents'); + }, + watcher, + 'change', + 'change', + 'path/to/file.txt' + ); + }); +}); diff --git a/test/lib/item.spec.js b/test/lib/item.spec.js index 9d525e01..4499b53f 100644 --- a/test/lib/item.spec.js +++ b/test/lib/item.spec.js @@ -1,7 +1,10 @@ 'use strict'; const Item = require('../../lib/item'); -const assert = require('../helper').assert; +const helper = require('../helper'); + +const assert = helper.assert; +const assertEvent = helper.assertEvent; describe('Item', function() { describe('constructor', function() { @@ -372,4 +375,36 @@ describe('Item', function() { }); }); } + + describe('#notifyChange()', function() { + it('emits a change event', function() { + const item = new Item(); + const watcher = item.getWatcher(); + assertEvent( + function() { + item.notifyChange('/path/to/item'); + }, + watcher, + 'change', + 'change', + '/path/to/item' + ); + }); + }); + + describe('#notifyRename()', function() { + it('emits a rename event', function() { + const item = new Item(); + const watcher = item.getWatcher(); + assertEvent( + function() { + item.notifyRename('/path/to/item'); + }, + watcher, + 'change', + 'rename', + '/path/to/item' + ); + }); + }); });