From faf8883049d6a15e9dcf7e4cfb936e1790c9f3b1 Mon Sep 17 00:00:00 2001 From: Jean Lauliac Date: Tue, 6 Feb 2018 13:20:56 +0000 Subject: [PATCH] jest-haste-map: fork watchman watcher from sane to enable custom features (#5387) --- .../src/__tests__/index.test.js | 23 +- packages/jest-haste-map/src/index.js | 3 +- .../src/lib/watchman_watcher.js | 325 ++++++++++++++++++ 3 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 packages/jest-haste-map/src/lib/watchman_watcher.js diff --git a/packages/jest-haste-map/src/__tests__/index.test.js b/packages/jest-haste-map/src/__tests__/index.test.js index 49da71c7ffb2..dbee3e731209 100644 --- a/packages/jest-haste-map/src/__tests__/index.test.js +++ b/packages/jest-haste-map/src/__tests__/index.test.js @@ -46,20 +46,25 @@ jest.mock('../crawlers/watchman', () => }), ); +const mockWatcherConstructor = jest.fn(root => { + const EventEmitter = require('events').EventEmitter; + mockEmitters[root] = new EventEmitter(); + mockEmitters[root].close = jest.fn(callback => callback()); + setTimeout(() => mockEmitters[root].emit('ready'), 0); + return mockEmitters[root]; +}); + jest.mock('sane', () => { - const watcher = jest.fn(root => { - const EventEmitter = require('events').EventEmitter; - mockEmitters[root] = new EventEmitter(); - mockEmitters[root].close = jest.fn(callback => callback()); - setTimeout(() => mockEmitters[root].emit('ready'), 0); - return mockEmitters[root]; - }); return { - NodeWatcher: watcher, - WatchmanWatcher: watcher, + NodeWatcher: mockWatcherConstructor, + WatchmanWatcher: mockWatcherConstructor, }; }); +jest.mock('../lib/watchman_watcher.js', () => { + return mockWatcherConstructor; +}); + let mockChangedFiles; let mockFs; diff --git a/packages/jest-haste-map/src/index.js b/packages/jest-haste-map/src/index.js index c2f19e1d9e3c..1b7d32323d9e 100644 --- a/packages/jest-haste-map/src/index.js +++ b/packages/jest-haste-map/src/index.js @@ -39,6 +39,7 @@ import getMockName from './get_mock_name'; import getPlatformExtension from './lib/get_platform_extension'; import normalizePathSep from './lib/normalize_path_sep'; import Worker from 'jest-worker'; +import WatchmanWatcher from './lib/watchman_watcher'; // eslint-disable-next-line import/default import nodeCrawl from './crawlers/node'; @@ -608,7 +609,7 @@ class HasteMap extends EventEmitter { const Watcher = canUseWatchman && this._options.useWatchman - ? sane.WatchmanWatcher + ? WatchmanWatcher : os.platform() === 'darwin' ? sane.FSEventsWatcher : sane.NodeWatcher; const extensions = this._options.extensions; const ignorePattern = this._options.ignorePattern; diff --git a/packages/jest-haste-map/src/lib/watchman_watcher.js b/packages/jest-haste-map/src/lib/watchman_watcher.js new file mode 100644 index 000000000000..13da661bce61 --- /dev/null +++ b/packages/jest-haste-map/src/lib/watchman_watcher.js @@ -0,0 +1,325 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import fs from 'fs'; +import path from 'path'; +import assert from 'assert'; +import common from 'sane/src/common'; +import watchman from 'fb-watchman'; +import {EventEmitter} from 'events'; +import RecrawlWarning from 'sane/src/utils/recrawl-warning-dedupe'; + +const CHANGE_EVENT = common.CHANGE_EVENT; +const DELETE_EVENT = common.DELETE_EVENT; +const ADD_EVENT = common.ADD_EVENT; +const ALL_EVENT = common.ALL_EVENT; +const SUB_NAME = 'sane-sub'; + +/** + * Watches `dir`. + * + * @class PollWatcher + * @param String dir + * @param {Object} opts + * @public + */ + +export default function WatchmanWatcher(dir, opts) { + common.assignOptions(this, opts); + this.root = path.resolve(dir); + this.init(); +} + +// eslint-disable-next-line no-proto +WatchmanWatcher.prototype.__proto__ = EventEmitter.prototype; + +/** + * Run the watchman `watch` command on the root and subscribe to changes. + * + * @private + */ + +WatchmanWatcher.prototype.init = function() { + if (this.client) { + this.client.removeAllListeners(); + } + + const self = this; + this.client = new watchman.Client(); + this.client.on('error', error => { + self.emit('error', error); + }); + this.client.on('subscription', this.handleChangeEvent.bind(this)); + this.client.on('end', () => { + console.warn('[sane] Warning: Lost connection to watchman, reconnecting..'); + self.init(); + }); + + this.watchProjectInfo = null; + + function getWatchRoot() { + return self.watchProjectInfo ? self.watchProjectInfo.root : self.root; + } + + function onCapability(error, resp) { + if (handleError(self, error)) { + // The Watchman watcher is unusable on this system, we cannot continue + return; + } + + handleWarning(resp); + + self.capabilities = resp.capabilities; + + if (self.capabilities.relative_root) { + self.client.command(['watch-project', getWatchRoot()], onWatchProject); + } else { + self.client.command(['watch', getWatchRoot()], onWatch); + } + } + + function onWatchProject(error, resp) { + if (handleError(self, error)) { + return; + } + + handleWarning(resp); + + self.watchProjectInfo = { + relativePath: resp.relative_path ? resp.relative_path : '', + root: resp.watch, + }; + + self.client.command(['clock', getWatchRoot()], onClock); + } + + function onWatch(error, resp) { + if (handleError(self, error)) { + return; + } + + handleWarning(resp); + + self.client.command(['clock', getWatchRoot()], onClock); + } + + function onClock(error, resp) { + if (handleError(self, error)) { + return; + } + + handleWarning(resp); + + const options = { + fields: ['name', 'exists', 'new'], + since: resp.clock, + }; + + // If the server has the wildmatch capability available it supports + // the recursive **/*.foo style match and we can offload our globs + // to the watchman server. This saves both on data size to be + // communicated back to us and compute for evaluating the globs + // in our node process. + if (self.capabilities.wildmatch) { + if (self.globs.length === 0) { + if (!self.dot) { + // Make sure we honor the dot option if even we're not using globs. + options.expression = [ + 'match', + '**', + 'wholename', + { + includedotfiles: false, + }, + ]; + } + } else { + options.expression = ['anyof']; + for (const i in self.globs) { + options.expression.push([ + 'match', + self.globs[i], + 'wholename', + { + includedotfiles: self.dot, + }, + ]); + } + } + } + + if (self.capabilities.relative_root) { + options.relative_root = self.watchProjectInfo.relativePath; + } + + self.client.command( + ['subscribe', getWatchRoot(), SUB_NAME, options], + onSubscribe, + ); + } + + function onSubscribe(error, resp) { + if (handleError(self, error)) { + return; + } + + handleWarning(resp); + + self.emit('ready'); + } + + self.client.capabilityCheck( + { + optional: ['wildmatch', 'relative_root'], + }, + onCapability, + ); +}; + +/** + * Handles a change event coming from the subscription. + * + * @param {Object} resp + * @private + */ + +WatchmanWatcher.prototype.handleChangeEvent = function(resp) { + assert.equal(resp.subscription, SUB_NAME, 'Invalid subscription event.'); + if (resp.is_fresh_instance) { + this.emit('fresh_instance'); + } + if (resp.is_fresh_instance) { + this.emit('fresh_instance'); + } + if (Array.isArray(resp.files)) { + resp.files.forEach(this.handleFileChange, this); + } +}; + +/** + * Handles a single change event record. + * + * @param {Object} changeDescriptor + * @private + */ + +WatchmanWatcher.prototype.handleFileChange = function(changeDescriptor) { + const self = this; + let absPath; + let relativePath; + + if (this.capabilities.relative_root) { + relativePath = changeDescriptor.name; + absPath = path.join( + this.watchProjectInfo.root, + this.watchProjectInfo.relativePath, + relativePath, + ); + } else { + absPath = path.join(this.root, changeDescriptor.name); + relativePath = changeDescriptor.name; + } + + if ( + !(self.capabilities.wildmatch && !this.hasIgnore) && + !common.isFileIncluded(this.globs, this.dot, this.doIgnore, relativePath) + ) { + return; + } + + if (!changeDescriptor.exists) { + self.emitEvent(DELETE_EVENT, relativePath, self.root); + } else { + fs.lstat(absPath, (error, stat) => { + // Files can be deleted between the event and the lstat call + // the most reliable thing to do here is to ignore the event. + if (error && error.code === 'ENOENT') { + return; + } + + if (handleError(self, error)) { + return; + } + + const eventType = changeDescriptor.new ? ADD_EVENT : CHANGE_EVENT; + + // Change event on dirs are mostly useless. + if (!(eventType === CHANGE_EVENT && stat.isDirectory())) { + self.emitEvent(eventType, relativePath, self.root, stat); + } + }); + } +}; + +/** + * Dispatches the event. + * + * @param {string} eventType + * @param {string} filepath + * @param {string} root + * @param {fs.Stat} stat + * @private + */ + +WatchmanWatcher.prototype.emitEvent = function( + eventType, + filepath, + root, + stat, +) { + this.emit(eventType, filepath, root, stat); + this.emit(ALL_EVENT, eventType, filepath, root, stat); +}; + +/** + * Closes the watcher. + * + * @param {function} callback + * @private + */ + +WatchmanWatcher.prototype.close = function(callback) { + this.client.removeAllListeners(); + this.client.end(); + callback && callback(null, true); +}; + +/** + * Handles an error and returns true if exists. + * + * @param {WatchmanWatcher} self + * @param {Error} error + * @private + */ + +function handleError(self, error) { + if (error != null) { + self.emit('error', error); + return true; + } else { + return false; + } +} + +/** + * Handles a warning in the watchman resp object. + * + * @param {object} resp + * @private + */ + +function handleWarning(resp) { + if ('warning' in resp) { + if (RecrawlWarning.isRecrawlWarningDupe(resp.warning)) { + return true; + } + console.warn(resp.warning); + return true; + } else { + return false; + } +}