From c17c0f6bf7025f8e8c64eee81f507d16cdbdb746 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Wed, 31 Jul 2024 23:29:31 +0000 Subject: [PATCH] Massive rewrite --- package.json | 14 +- src/anymatch.ts | 71 +- src/index.ts | 31 +- src/nodefs-handler.ts | 45 +- src/v4.ts | 1201 +++++++++++++++++++++++ test-v4.mjs | 2138 +++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 7 files changed, 3395 insertions(+), 107 deletions(-) create mode 100644 src/v4.ts create mode 100644 test-v4.mjs diff --git a/package.json b/package.json index 541abd76..7ecbff8b 100644 --- a/package.json +++ b/package.json @@ -12,18 +12,14 @@ "node": ">= 18" }, "type": "module", - "main": "lib/index.js", + "main": "lib/v4.js", "dependencies": { - "is-binary-path": "2.1.0", - "normalize-path": "3.0.0", - "readdirp": "github:paulmillr/readdirp" }, "devDependencies": { "@eslint/js": "^9.3.0", "@types/node": "20.12.12", "chai": "4.3.4", "eslint": "^8.57.0", - "globals": "^15.3.0", "rimraf": "5.0.5", "sinon": "12.0.1", "sinon-chai": "3.7.0", @@ -32,8 +28,8 @@ "upath": "2.0.1" }, "files": [ - "lib/*.js", - "lib/*.d.ts" + "lib/v4.js", + "lib/v4.d.ts" ], "repository": { "type": "git", @@ -45,8 +41,8 @@ "license": "MIT", "scripts": { "build": "tsc", - "lint": "eslint .", - "test": "npm run build && npm run lint && node --test" + "lint": "eslint src/v4.ts", + "test": "npm run build && npm run lint && node --test test-v4.mjs" }, "keywords": [ "fs", diff --git a/src/anymatch.ts b/src/anymatch.ts index 43278ca3..e9dd14d5 100644 --- a/src/anymatch.ts +++ b/src/anymatch.ts @@ -1,51 +1,35 @@ import normalizePath from 'normalize-path'; import path from 'node:path'; -import type {Stats} from 'node:fs'; +import type { Stats } from 'node:fs'; export type MatchFunction = (val: string, stats?: Stats) => boolean; export interface MatcherObject { path: string; recursive?: boolean; } -export type Matcher = - | string - | RegExp - | MatchFunction - | MatcherObject; +export type Matcher = string | RegExp | MatchFunction | MatcherObject; function arrify(item: T | T[]): T[] { return Array.isArray(item) ? item : [item]; } export const isMatcherObject = (matcher: Matcher): matcher is MatcherObject => - typeof matcher === 'object' && - matcher !== null && - !(matcher instanceof RegExp); + typeof matcher === 'object' && matcher !== null && !(matcher instanceof RegExp); /** * @param {AnymatchPattern} matcher * @returns {MatchFunction} */ const createPattern = (matcher: Matcher): MatchFunction => { - if (typeof matcher === 'function') { - return matcher; - } - if (typeof matcher === 'string') { - return (string) => matcher === string; - } - if (matcher instanceof RegExp) { - return (string) => matcher.test(string); - } + if (typeof matcher === 'function') return matcher; + if (typeof matcher === 'string') return (string) => matcher === string; + if (matcher instanceof RegExp) return (string) => matcher.test(string); if (typeof matcher === 'object' && matcher !== null) { return (string) => { - if (matcher.path === string) { - return true; - } + if (matcher.path === string) return true; if (matcher.recursive) { const relative = path.relative(matcher.path, string); - if (!relative) { - return false; - } + if (!relative) return false; return !relative.startsWith('..') && !path.isAbsolute(relative); } return false; @@ -60,20 +44,12 @@ const createPattern = (matcher: Matcher): MatchFunction => { * @param {Boolean} returnIndex * @returns {boolean|number} */ -function matchPatterns( - patterns: MatchFunction[], - testString: string, - stats?: Stats -): boolean { +function matchPatterns(patterns: MatchFunction[], testString: string, stats?: Stats): boolean { const path = normalizePath(testString); - for (let index = 0; index < patterns.length; index++) { const pattern = patterns[index]; - if (pattern(path, stats)) { - return true; - } + if (pattern(path, stats)) return true; } - return false; } @@ -83,34 +59,19 @@ function matchPatterns( * @param {object} options * @returns {boolean|number|Function} */ -function anymatch( - matchers: Matcher[], - testString: undefined -): MatchFunction; -function anymatch( - matchers: Matcher[], - testString: string -): boolean; -function anymatch( - matchers: Matcher[], - testString: string|undefined -): boolean|MatchFunction { - if (matchers == null) { - throw new TypeError('anymatch: specify first argument'); - } - +function anymatch(matchers: Matcher[], testString: undefined): MatchFunction; +function anymatch(matchers: Matcher[], testString: string): boolean; +function anymatch(matchers: Matcher[], testString: string | undefined): boolean | MatchFunction { + if (matchers == null) throw new TypeError('anymatch: specify first argument'); // Early cache for matchers. const matchersArray = arrify(matchers); - const patterns = matchersArray - .map(matcher => createPattern(matcher)); - + const patterns = matchersArray.map((matcher) => createPattern(matcher)); if (testString == null) { return (testString: string, stats?: Stats): boolean => { return matchPatterns(patterns, testString, stats); }; } - return matchPatterns(patterns, testString); } -export {anymatch}; +export { anymatch }; diff --git a/src/index.ts b/src/index.ts index 918f14c2..ea36c17a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import { EventEmitter } from 'node:events'; import sysPath from 'node:path'; import readdirp from 'readdirp'; -import {stat, readdir} from 'node:fs/promises'; +import { stat, readdir } from 'node:fs/promises'; import NodeFsHandler from './nodefs-handler.js'; import { anymatch, MatchFunction, isMatcherObject, Matcher } from './anymatch.js'; @@ -83,12 +83,8 @@ const normalizeIgnored = }; const getAbsolutePath = (path, cwd) => { - if (sysPath.isAbsolute(path)) { - return path; - } - if (path.startsWith(BANG)) { - return BANG + sysPath.join(cwd, path.slice(1)); - } + if (sysPath.isAbsolute(path)) return path; + if (path.startsWith('!')) return '!' + sysPath.join(cwd, path.slice(1)); // '!' == './' return sysPath.join(cwd, path); }; @@ -112,7 +108,7 @@ class DirEntry { add(item) { const { items } = this; if (!items) return; - if (item !== ONE_DOT && item !== TWO_DOTS) items.add(item); + if (item !== '.' && item !== '..') items.add(item); } async remove(item) { @@ -169,10 +165,11 @@ export class WatchHelper { constructor(path: string, follow: boolean, fsw: any) { this.fsw = fsw; const watchPath = path; - this.path = path = path.replace(REPLACER_RE, EMPTY_STR); + this.path = path = path.replace(REPLACER_RE, ''); this.watchPath = watchPath; this.fullWatchPath = sysPath.resolve(watchPath); /** @type {object|boolean} */ + this.dirParts = []; this.dirParts.forEach((parts) => { if (parts.length > 1) parts.pop(); @@ -182,12 +179,13 @@ export class WatchHelper { } entryPath(entry) { + // basically sysPath.absolute return sysPath.join(this.watchPath, sysPath.relative(this.watchPath, entry.fullPath)); } filterPath(entry) { const { stats } = entry; - if (stats && stats.isSymbolicLink()) return this.filterDir(entry); + if (stats && stats.isSymbolicLink()) return this.filterDir(entry); /// WUT?! symlink can be file too const resolvedPath = this.entryPath(entry); return this.fsw._isntIgnored(resolvedPath, stats) && this.fsw._hasReadPermissions(stats); } @@ -345,6 +343,7 @@ export class FSWatcher extends EventEmitter { } if (opts.ignored) opts.ignored = arrify(opts.ignored); + // Done to emit ready only once, but each 'add' will increase that let readyCalls = 0; this._emitReady = () => { readyCalls++; @@ -472,6 +471,7 @@ export class FSWatcher extends EventEmitter { this._closePath(path); + // TWICE! this._addIgnoredPath(path); if (this._watched.has(path)) { this._addIgnoredPath({ @@ -507,7 +507,7 @@ export class FSWatcher extends EventEmitter { ); this._streams.forEach((stream) => stream.destroy()); this._userIgnored = undefined; - this._readyCount = 0; + this._readyCount = 0; // allows to re-start this._readyEmitted = false; this._watched.forEach((dirent) => dirent.dispose()); ['closers', 'watched', 'streams', 'symlinkPaths', 'throttled'].forEach((key) => { @@ -544,6 +544,7 @@ export class FSWatcher extends EventEmitter { /** * Normalize and emit events. * Calling _emit DOES NOT MEAN emit() would be called! + * val1 == stats, others are unused * @param {EventName} event Type of event * @param {Path} path File or directory path * @param {*=} val1 arguments to be passed with event @@ -907,6 +908,7 @@ export class FSWatcher extends EventEmitter { _closeFile(path) { const closers = this._closers.get(path); if (!closers) return; + // no promise handling here closers.forEach((closer) => closer()); this._closers.delete(path); } @@ -931,10 +933,13 @@ export class FSWatcher extends EventEmitter { const options = { type: EV.ALL, alwaysStat: true, lstat: true, ...opts }; let stream = readdirp(root, options); this._streams.add(stream); - stream.once(STR_CLOSE, () => { + // possible mem leak if emited before end + stream.once('close', () => { + console.log('readdirp close'); stream = undefined; }); - stream.once(STR_END, () => { + stream.once('end', () => { + console.log('readdirp end'); if (stream) { this._streams.delete(stream); stream = undefined; diff --git a/src/nodefs-handler.ts b/src/nodefs-handler.ts index efbd486b..293e55c5 100644 --- a/src/nodefs-handler.ts +++ b/src/nodefs-handler.ts @@ -19,12 +19,7 @@ import { } from './constants.js'; import * as EV from './events.js'; import type { FSWatcher, WatchHelper, FSWInstanceOptions } from './index.js'; -import { - open, - stat, - lstat, - realpath as fsrealpath -} from 'node:fs/promises'; +import { open, stat, lstat, realpath as fsrealpath } from 'node:fs/promises'; const THROTTLE_MODE_WATCH = 'watch'; @@ -41,19 +36,14 @@ const foreach = (val, fn) => { const addAndConvert = (main, prop, item) => { let container = main[prop]; - if (!(container instanceof Set)) { - main[prop] = container = new Set([container]); - } + if (!(container instanceof Set)) main[prop] = container = new Set([container]); container.add(item); }; const clearItem = (cont) => (key) => { const set = cont[key]; - if (set instanceof Set) { - set.clear(); - } else { - delete cont[key]; - } + if (set instanceof Set) set.clear(); + else delete cont[key]; }; const delFromSet = (main, prop, item) => { @@ -117,9 +107,13 @@ function createFsWatchInstance( } }; try { - return fs.watch(path, { - persistent: options.persistent - }, handleEvent); + return fs.watch( + path, + { + persistent: options.persistent, + }, + handleEvent + ); } catch (error) { errHandler(error); } @@ -329,7 +323,7 @@ export default class NodeFsHandler { parent.add(basename); const absolutePath = sysPath.resolve(path); const options: Partial = { - persistent: opts.persistent + persistent: opts.persistent, }; if (!listener) listener = EMPTY_FN; @@ -359,9 +353,7 @@ export default class NodeFsHandler { * @returns {Function} closer for the watcher instance */ _handleFile(file, stats, initialAdd) { - if (this.fsw.closed) { - return; - } + if (this.fsw.closed) return; const dirname = sysPath.dirname(file); const basename = sysPath.basename(file); const parent = this.fsw._getWatchedDir(dirname); @@ -372,12 +364,10 @@ export default class NodeFsHandler { if (parent.has(basename)) return; const listener = async (path, newStats) => { - console.log({path, newStats}); if (!this.fsw._throttle(THROTTLE_MODE_WATCH, file, 5)) return; if (!newStats || newStats.mtimeMs === 0) { try { const newStats = await stat(file); - console.log({newStats, prevStats}); if (this.fsw.closed) return; // Check that change event was not fired because of changed only accessTime. const at = newStats.atimeMs; @@ -393,7 +383,6 @@ export default class NodeFsHandler { prevStats = newStats; } } catch (error) { - console.log({error}); // Fix issues where mtime is null but file is still present this.fsw._remove(dirname, basename); } @@ -536,10 +525,7 @@ export default class NodeFsHandler { previous .getChildren() .filter((item) => { - return ( - item !== directory && - !current.has(item) - ); + return item !== directory && !current.has(item); }) .forEach((item) => { this.fsw._remove(directory, item); @@ -568,6 +554,7 @@ export default class NodeFsHandler { const parentDir = this.fsw._getWatchedDir(sysPath.dirname(dir)); const tracked = parentDir.has(sysPath.basename(dir)); if (!(initialAdd && this.fsw.options.ignoreInitial) && !target && !tracked) { + console.log('addDir', dir, new Error().stack); this.fsw._emit(EV.ADD_DIR, dir, stats); } @@ -604,7 +591,7 @@ export default class NodeFsHandler { * @param {String=} target Child path actually targeted for watch * @returns {Promise} */ - async _addToNodeFs(path, initialAdd, priorWh: WatchHelper|undefined, depth, target?: string) { + async _addToNodeFs(path, initialAdd, priorWh: WatchHelper | undefined, depth, target?: string) { const ready = this.fsw._emitReady; if (this.fsw._isIgnored(path) || this.fsw.closed) { ready(); diff --git a/src/v4.ts b/src/v4.ts new file mode 100644 index 00000000..05af71fb --- /dev/null +++ b/src/v4.ts @@ -0,0 +1,1201 @@ +import fs from 'node:fs'; +import { EventEmitter } from 'node:events'; +import sysPath from 'node:path'; +import { readdir, lstat, open, stat, realpath as fsrealpath } from 'node:fs/promises'; +import { type as osType } from 'os'; +import type { BigIntStats, Stats as NodeStats } from 'node:fs'; +// readlink +// Platform information +const { platform } = process; +export const isWindows = platform === 'win32'; +export const isMacos = platform === 'darwin'; +export const isLinux = platform === 'linux'; +export const isIBMi = osType() === 'OS400'; +// prettier-ignore +const binaryExtensions = new Set([ + '3dm', '3ds', '3g2', '3gp', '7z', 'a', 'aac', 'adp', 'afdesign', 'afphoto', 'afpub', 'ai', + 'aif', 'aiff', 'alz', 'ape', 'apk', 'appimage', 'ar', 'arj', 'asf', 'au', 'avi', + 'bak', 'baml', 'bh', 'bin', 'bk', 'bmp', 'btif', 'bz2', 'bzip2', + 'cab', 'caf', 'cgm', 'class', 'cmx', 'cpio', 'cr2', 'cur', 'dat', 'dcm', 'deb', 'dex', 'djvu', + 'dll', 'dmg', 'dng', 'doc', 'docm', 'docx', 'dot', 'dotm', 'dra', 'DS_Store', 'dsk', 'dts', + 'dtshd', 'dvb', 'dwg', 'dxf', + 'ecelp4800', 'ecelp7470', 'ecelp9600', 'egg', 'eol', 'eot', 'epub', 'exe', + 'f4v', 'fbs', 'fh', 'fla', 'flac', 'flatpak', 'fli', 'flv', 'fpx', 'fst', 'fvt', + 'g3', 'gh', 'gif', 'graffle', 'gz', 'gzip', + 'h261', 'h263', 'h264', 'icns', 'ico', 'ief', 'img', 'ipa', 'iso', + 'jar', 'jpeg', 'jpg', 'jpgv', 'jpm', 'jxr', 'key', 'ktx', + 'lha', 'lib', 'lvp', 'lz', 'lzh', 'lzma', 'lzo', + 'm3u', 'm4a', 'm4v', 'mar', 'mdi', 'mht', 'mid', 'midi', 'mj2', 'mka', 'mkv', 'mmr','mng', + 'mobi', 'mov', 'movie', 'mp3', + 'mp4', 'mp4a', 'mpeg', 'mpg', 'mpga', 'mxu', + 'nef', 'npx', 'numbers', 'nupkg', + 'o', 'odp', 'ods', 'odt', 'oga', 'ogg', 'ogv', 'otf', 'ott', + 'pages', 'pbm', 'pcx', 'pdb', 'pdf', 'pea', 'pgm', 'pic', 'png', 'pnm', 'pot', 'potm', + 'potx', 'ppa', 'ppam', + 'ppm', 'pps', 'ppsm', 'ppsx', 'ppt', 'pptm', 'pptx', 'psd', 'pya', 'pyc', 'pyo', 'pyv', + 'qt', + 'rar', 'ras', 'raw', 'resources', 'rgb', 'rip', 'rlc', 'rmf', 'rmvb', 'rpm', 'rtf', 'rz', + 's3m', 's7z', 'scpt', 'sgi', 'shar', 'snap', 'sil', 'sketch', 'slk', 'smv', 'snk', 'so', + 'stl', 'suo', 'sub', 'swf', + 'tar', 'tbz', 'tbz2', 'tga', 'tgz', 'thmx', 'tif', 'tiff', 'tlz', 'ttc', 'ttf', 'txz', + 'udf', 'uvh', 'uvi', 'uvm', 'uvp', 'uvs', 'uvu', + 'viv', 'vob', + 'war', 'wav', 'wax', 'wbmp', 'wdp', 'weba', 'webm', 'webp', 'whl', 'wim', 'wm', 'wma', + 'wmv', 'wmx', 'woff', 'woff2', 'wrm', 'wvx', + 'xbm', 'xif', 'xla', 'xlam', 'xls', 'xlsb', 'xlsm', 'xlsx', 'xlt', 'xltm', 'xltx', 'xm', + 'xmind', 'xpi', 'xpm', 'xwd', 'xz', + 'z', 'zip', 'zipx', +]); +const isBinaryPath = (filePath) => + binaryExtensions.has(sysPath.extname(filePath).slice(1).toLowerCase()); + +// Small internal primitive to limit concurrency +// TODO: identify potential bugs. Research hpw other libraries do this +function limit(concurrencyLimit?: number) { + if (concurrencyLimit === undefined) return (fn: () => T): T => fn(); // Fast path for no limit + let currentlyProcessing = 0; + const queue: ((value?: unknown) => void)[] = []; + const next = () => { + if (!queue.length) return; + if (currentlyProcessing >= concurrencyLimit) return; + currentlyProcessing++; + const first = queue.shift(); + if (!first) throw new Error('empty queue'); // should not happen + first(); + }; + return (fn: () => Promise): Promise => + new Promise((resolve, reject) => { + queue.push(() => + Promise.resolve() + .then(fn) + .then(resolve) + .catch(reject) + .finally(() => { + currentlyProcessing--; + next(); + }) + ); + next(); + }); +} + +// prettier-ignore +type EventName = 'all' | 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' | 'raw' | 'error' | 'ready'; +type Stats = NodeStats | BigIntStats; +type Path = string; +type ThrottleType = 'readdir' | 'watch' | 'add' | 'remove' | 'change'; +type EmitArgs = [EventName, Path, any?, any?, any?]; + +const arrify = (value: T | T[] = []): T[] => (Array.isArray(value) ? value : [value]); +const flatten = (list: T[] | T[][], result = []): T[] => { + list.forEach((item) => { + if (Array.isArray(item)) flatten(item, result); + else result.push(item); + }); + return result; +}; + +/** + * Check for read permissions. + * @param stats - object, result of fs_stat + * @returns indicates whether the file can be read + */ +function hasReadPermissions(stats: Stats) { + return Boolean(Number(stats.mode) & 0o400); +} + +const NORMAL_FLOW_ERRORS = new Set(['ENOENT', 'EPERM', 'EACCES', 'ELOOP']); + +// Legacy list of user events +export const EV = { + ALL: 'all', + READY: 'ready', + ADD: 'add', + CHANGE: 'change', + ADD_DIR: 'addDir', + UNLINK: 'unlink', + UNLINK_DIR: 'unlinkDir', + RAW: 'raw', + ERROR: 'error', +}; + +/* +Re-usable instances of fs.watch and fs.watchFile. Architecture rationale: +- FSWatcher can have multiple listeners here: + - followSymlinks + two symlinks to the same file + - directory + symlink inside + - different paths (absolute + relative) without cwd +- Multiple FSWatcher-s can reuse the same watcher +- This means we cannot just add Set for err/raw (it will require reference counting) + Should be very simple code to create watchers only, all logic and IO should be handled inside of FSWatcher +- Returns sync 'closer' function which should ensure that no events emitted after closing. + This is needed for cases when the directory was moved. +*/ +type WatchHandlers = { + listener: (path: string, stats?: Stats) => void; + errHandler: (err: Error, path?: Path, fullPath?: Path) => void; + rawEmitter: (ev: fs.WatchEventType, path: string, opts: unknown) => void; +}; + +type WatchInstancePartial = { + listeners: Set; + errHandlers: Set; + rawEmitters: Set; + options: Partial; +}; + +type WatchInstance = WatchInstancePartial & { watcher?: T }; + +type WatchFn = { + upgrade?: boolean; + create: (path: Path, fullPath: Path, instance: WatchInstancePartial) => T; + close: (path: Path, fullPath: Path, watcher?: T) => void; +}; + +const watchWrapper = (opts: WatchFn) => { + const instances: Map> = new Map(); + const { create, close, upgrade } = opts; + return ( + path: Path, + fullPath: Path, + options: Partial, + handlers: WatchHandlers + ) => { + let cont: WatchInstance | undefined = instances.get(fullPath); + const { listener, errHandler, rawEmitter } = handlers; + const copts = cont && cont.options; + // This seems like a rare case. + // In theory, we can upgrade 'watch' too, but instead + // fallback to creating non-global instance if different persistence + let differentOptions = + copts && (copts.persistent < options.persistent || copts.interval > options.interval); + if (upgrade && differentOptions) { + // "Upgrade" the watcher to persistence or a quicker interval. + // This creates some unlikely edge case issues if the user mixes + // settings in a very weird way, but solving for those cases + // doesn't seem worthwhile for the added complexity. + close(path, fullPath, cont.watcher); + cont = undefined; + differentOptions = false; // upgraded, now options are the same + } + if (!cont || differentOptions) { + cont = { listeners: new Set(), errHandlers: new Set(), rawEmitters: new Set(), options }; + try { + cont.watcher = create(path, fullPath, cont); + // non-global instance if options still different + if (!differentOptions) instances.set(fullPath, cont); + } catch (error) { + errHandler(error); + return; + } + } + cont.listeners.add(listener); + cont.errHandlers.add(errHandler); + cont.rawEmitters.add(rawEmitter); + return () => { + cont.listeners.delete(listener); + cont.errHandlers.delete(errHandler); + cont.rawEmitters.delete(rawEmitter); + if (cont.listeners.size) return; // All listeners left, lets close + if (!differentOptions) instances.delete(fullPath); // when same options: use global + close(path, fullPath, cont.watcher); + cont.listeners.clear(); + cont.errHandlers.clear(); + cont.rawEmitters.clear(); + cont.watcher = undefined; + Object.freeze(cont); + }; + }; +}; + +const fsWatch = watchWrapper({ + create(path, fullPath, instance) { + const { options, listeners, rawEmitters, errHandlers } = instance; + // TODO: why it is using path instead full path? + return fs + .watch(path, { persistent: options.persistent }, (rawEvent, evPath) => { + for (const fn of listeners) fn(path); + for (const fn of rawEmitters) fn(rawEvent, evPath, { watchedPath: path }); + // NOTE: previously there was re-emitting event "for files from a + // directory's watcher in case the file's watcher misses it" + // However, this is incorrect and can cause race-conditions if current + // watcher is already closed. + // Please open issue if there is a reproducible case for this. + }) + .on('error', (err) => { + for (const fn of errHandlers) fn(err, path, fullPath); + }); + }, + close(path, fullPath, watcher) { + if (watcher) watcher.close(); + }, +}); + +const fsWatchFile = watchWrapper({ + upgrade: true, + create(path, fullPath, instance) { + const { listeners, rawEmitters, options } = instance; + return fs.watchFile(fullPath, options, (curr, prev) => { + for (const rawEmitter of rawEmitters) rawEmitter('change', fullPath, { curr, prev }); + const currmtime = curr.mtimeMs; + if (curr.size !== prev.size || currmtime > prev.mtimeMs || currmtime === 0) { + for (const listener of listeners) listener(path, curr); + } + }); + }, + // eslint-disable-next-line + close(path, fullPath, _watcher) { + fs.unwatchFile(fullPath); + }, +}); + +// Matcher +type MatchFunction = (path: string, stats?: Stats) => boolean; +interface MatcherObject { + path: string; + recursive?: boolean; +} +type Matcher = string | RegExp | MatchFunction | MatcherObject; +function isMatcherObject(matcher: Matcher): matcher is MatcherObject { + return typeof matcher === 'object' && matcher !== null && !(matcher instanceof RegExp); +} +type AWF = { + stabilityThreshold: number; + pollInterval: number; +}; + +type BasicOpts = { + persistent: boolean; + ignoreInitial: boolean; + followSymlinks: boolean; + cwd?: string; + // Polling + usePolling: boolean; + interval: number; + binaryInterval: number; // Used only for pooling and if diferrent from interval + + alwaysStat?: boolean; + depth?: number; + ignorePermissionErrors: boolean; + atomic: boolean | number; // or a custom 'atomicity delay', in milliseconds (default 100) + useAsync?: boolean; // Use async for stat/readlink methods + + ioLimit?: number; // Limit parallel IO operations (CPU usage + OS limits) +}; + +export type ChokidarOptions = Partial< + BasicOpts & { + ignored: string | ((path: string) => boolean); // And what about regexps? + awaitWriteFinish: boolean | Partial; + } +>; + +export type FSWInstanceOptions = BasicOpts & { + ignored: Matcher[]; // string | fn -> + awaitWriteFinish: false | AWF; +}; + +/** + * Watches files & directories for changes. Emitted events: + * `add`, `addDir`, `change`, `unlink`, `unlinkDir`, `all`, `error` + * + * new FSWatcher() + * .add(directories) + * .on('add', path => log('File', path, 'was added')) + */ +export class FSWatcher extends EventEmitter { + options: FSWInstanceOptions; + private watched: Map> = new Map(); + private closers: Map> = new Map(); + private ignoredPaths: Set = new Set(); + private throttled: Map> = new Map(); + private symlinkPaths: Map = new Map(); + closed: boolean = false; + private pendingWrites: Map = new Map(); + private pendingUnlinks: Map = new Map(); + private readyCount: number; + private emitReady: () => void; + private closePromise: Promise; + private userIgnored?: MatchFunction; + private readyEmitted: boolean = false; + // Performance debug related stuff. Not sure if worth exposing in API? + public metrics: Record = {}; + private ioLimit: ReturnType; + constructor(_opts: ChokidarOptions = {}) { + super(); + const awf = _opts.awaitWriteFinish; + const DEF_AWF = { stabilityThreshold: 2000, pollInterval: 100 }; + const opts: FSWInstanceOptions = { + // Defaults + persistent: true, + ignoreInitial: false, + ignorePermissionErrors: false, + interval: 100, + binaryInterval: 300, + followSymlinks: true, + usePolling: false, + useAsync: false, + atomic: true, // NOTE: overwritten later (depends on usePolling) + ..._opts, + // Change format + ignored: arrify(_opts.ignored), + awaitWriteFinish: + awf === true ? DEF_AWF : typeof awf === 'object' ? { ...DEF_AWF, ...awf } : false, + }; + // Always default to polling on IBM i because fs.watch() is not available on IBM i. + if (isIBMi) opts.usePolling = true; + // Editor atomic write normalization enabled by default with fs.watch + if (opts.atomic === undefined) opts.atomic = !opts.usePolling; + opts.atomic = typeof _opts.atomic === 'number' ? _opts.atomic : 100; + // Global override. Useful for developers, who need to force polling for all + // instances of chokidar, regardless of usage / dependency depth + const envPoll = process.env.CHOKIDAR_USEPOLLING; + if (envPoll !== undefined) { + const envLower = envPoll.toLowerCase(); + if (envLower === 'false' || envLower === '0') opts.usePolling = false; + else if (envLower === 'true' || envLower === '1') opts.usePolling = true; + else opts.usePolling = !!envLower; + } + const envInterval = process.env.CHOKIDAR_INTERVAL; + if (envInterval) opts.interval = Number.parseInt(envInterval, 10); + this.ioLimit = limit(opts.ioLimit); + // TODO: simplify. Currently it will easily lose things + // This seems done to emit ready only once, but each 'add' will increase that? + let readyCalls = 0; + this.emitReady = () => { + readyCalls++; + if (readyCalls >= this.readyCount) { + this.emitReady = () => {}; + this.readyEmitted = true; + // use process.nextTick to allow time for listener to be bound + process.nextTick(() => this.emit('ready')); + } + }; + // You’re frozen when your heart’s not open. + Object.freeze(opts); + this.options = opts; + } + // IO + private metric(name: string, inc = 1) { + if (!this.metrics[name]) this.metrics[name] = 0; + this.metrics[name] += inc; + } + private readdir(path: string) { + // dirent is available in node v18 on macos + win + linux + return this.ioLimit(async () => { + try { + return await readdir(path, { encoding: 'utf8', withFileTypes: true }); + } catch (err) { + if (!NORMAL_FLOW_ERRORS.has(err.code)) this.handleError(err); + return []; + } finally { + this.metric('readdir'); + } + }); + } + private lstat(path: string): Promise { + // Available in node v18: bigint allows access to 'mtimeNs' which has more precision than mtimeMs. + // It's no longer a float, which can't be compared. + // Also, there is no mtime/mode in DirEnt + return this.ioLimit(async () => { + try { + if (!this.options.useAsync) return fs.lstatSync(path, { bigint: true }); + return await lstat(path, { bigint: true }); + } catch (err) { + if (!NORMAL_FLOW_ERRORS.has(err.code)) this.handleError(err); + return; + } finally { + this.metric('lstat'); + } + }); + } + private stat(path: string): Promise { + // Available in node v18: bigint allows access to 'mtimeNs' which has more precision than mtimeMs. + // It's no longer a float, which can't be compared. + // Also, there is no mtime/mode in DirEnt + return this.ioLimit(async () => { + try { + if (!this.options.useAsync) return fs.statSync(path, {}); + return await stat(path, {}); + } catch (err) { + if (!NORMAL_FLOW_ERRORS.has(err.code)) this.handleError(err); + return; + } finally { + this.metric('stat'); + } + }); + } + // private readlink(path: string) { + // return this.ioLimit(async () => { + // try { + // if (!this.options.useAsync) return fs.readlinkSync(path, { encoding: 'utf8' }); + // return await readlink(path, { encoding: 'utf8' }); + // } catch (err) { + // if (!NORMAL_FLOW_ERRORS.has(err.code)) this.handleError(err); + // return; + // } finally { + // this.metric('readlink'); + // } + // }); + // } + private canOpen(path: string) { + return this.ioLimit(async () => { + try { + if (!this.options.useAsync) { + fs.closeSync(fs.openSync(path, 'r')); + } else { + await (await open(path, 'r')).close(); + } + } catch (err) { + return false; + } finally { + this.metric('canOpen'); + } + return true; + }); + } + // This mostly happens after removing file. Checks if directory still exists. + // TODO: either use real readdir (with updating information) or remove this + private async canOpenDir(dir: Path) { + dir = sysPath.resolve(dir); + return this.ioLimit(async () => { + const items = this.getWatchedDir(dir); + if (items.size > 0) return; + try { + await readdir(dir); + } catch (err) { + this.remove(sysPath.dirname(dir), sysPath.basename(dir)); + } + }); + } + // /IO + // Utils + /** + * Provides directory tracking objects + */ + private getWatchedDir(directory: string): Set { + const dir = sysPath.resolve(directory); + if (!this.watched.has(dir)) this.watched.set(dir, new Set()); + return this.watched.get(dir); + } + private normalizePath(path: Path) { + const { cwd } = this.options; + path = sysPath.normalize(path); + // TODO: do we really need that? only thing it does is using '//' instead of '\\' for network shares + // in windows. Path normalize already strips '//' in windows. + // > If SLASH_SLASH occurs at the beginning of path, it is not replaced + // > because "//StoragePC/DrivePool/Movies" is a valid network path + path = path.replace(/\\/g, '/'); + let prepend = false; + if (path.startsWith('//')) prepend = true; + const DOUBLE_SLASH_RE = /\/\//; + while (path.match(DOUBLE_SLASH_RE)) path = path.replace(DOUBLE_SLASH_RE, '/'); + if (prepend) path = '/' + path; + // NOTE: join will undo all normalization + if (cwd) path = sysPath.isAbsolute(path) ? path : sysPath.join(cwd, path); + return path; + } + private normalizePaths(paths: Path | Path[]) { + // TODO: do we really need flatten here? + paths = flatten(arrify(paths)); + if (!paths.every((p) => typeof p === 'string')) + throw new TypeError(`Non-string provided as watch path: ${paths}`); + return paths.map((i) => this.normalizePath(i)); + } + /** + * Helper utility for throttling + * @param actionType type being throttled + * @param path being acted upon + * @param ms duration to suppress duplicate actions + * @returns tracking object or false if action should be suppressed + */ + private throttle(actionType: ThrottleType, path: Path, ms: number) { + // NOTE: this is only used correctly in readdir for now. + // How it should work: + // - we process some event + // - same event happens in parallel multiple times + // - when we processed first event, we look at last throttled event + // - start processing it + // How it works now (except readdir): + // - we process first event (first change) + // - there is multiple parallel changes which we throw away + // - all changes which happened when we processed first event is lost + if (!this.throttled.has(actionType)) this.throttled.set(actionType, new Map()); + const action = this.throttled.get(actionType); + const actionPath = action.get(path); + if (actionPath) { + actionPath.count++; + return false; + } + const thr = { + ms, + timeout: undefined, + count: 0, + clear: () => { + const item = action.get(path); + const count = item ? item.count : 0; + action.delete(path); + if (item) { + if (item.timeout !== undefined) clearTimeout(item.timeout); + item.timeout = undefined; + } + if (thr.timeout !== undefined) clearTimeout(thr.timeout); + thr.timeout = undefined; + return count; + }, + }; + thr.timeout = setTimeout(thr.clear, ms); + action.set(path, thr); + return thr; + } + // /Utils + // Watcher + // + private addWatcher(closerPath: Path, path: Path, listener: WatchHandlers['listener']) { + const opts = this.options; + const directory = sysPath.dirname(path); + const basename = sysPath.basename(path); + this.getWatchedDir(directory).add(basename); + const absolutePath = sysPath.resolve(path); + const options: Partial = { + persistent: opts.persistent, + interval: + opts.binaryInterval !== opts.interval && isBinaryPath(basename) + ? opts.binaryInterval + : opts.interval, + }; + const fn = opts.usePolling ? fsWatchFile : fsWatch; + return this.ioLimit(async () => { + const closer = fn(path, absolutePath, options, { + listener: listener as any, + errHandler: (error: Error, path?: Path) => this.handleError(error, path), + rawEmitter: (...args) => this.emit('raw', ...args), + }); + if (closer) { + // closerPath = sysPath.resolve(closerPath); + const list = this.closers.get(closerPath); + if (!list) this.closers.set(closerPath, [closer]); + else list.push(closer); + } + }); + } + private closeWatcher(path: Path) { + //path = sysPath.resolve(path); + const closers = this.closers.get(path); + if (closers) { + for (const closer of closers) closer(); + this.closers.delete(path); + } + const dirname = sysPath.dirname(path); + const dir = this.getWatchedDir(dirname); + dir.delete(sysPath.basename(path)); + this.canOpenDir(dirname); + } + // /Watcher + + /** + * Handle added file, directory, or glob pattern. + * Delegates call to handleFile / _handleDir after checks. + * @param {String} path to file or ir + * @param {Boolean} initialAdd was the file added at watch instantiation? + * @param {Object} priorWh depth relative to user-supplied path + * @param {Number} depth Child path actually targeted for watch + * @param {String=} target Child path actually targeted for watch + * @returns {Promise} + */ + private async addToNodeFs( + path: Path, + initialAdd: boolean, + priorWh: string | undefined, + depth: number, + target?: string + ) { + // TODO: this is completely messed up + // - we need to use dirent from readdir on recursive call to itself + // - instead of handling symlinks/stats in single place it does + // it multiple times (in readdir, then same thing happens inside recursive addToNodeFs) + // - what makes this even worse, some edge cases handled in 'file', others in 'readdir' + // this means add('dir/file') has different behavior than add('dir') (and then looking at 'file') + // - symlinks handling is broken abomination which is also intervened with broken normalization and path handling + // - emitReady should be Promise.all on 'addWait' which returns when everything added + // instead of randomly placed 'readyCount' + // - these 200 lines should be collapsed to 30-50 + if (this.isIgnored(path) || this.closed) { + this.emitReady(); + return false; + } + const watchPath = priorWh ? priorWh : path; + const entryPath = (fullPath) => sysPath.join(watchPath, sysPath.relative(watchPath, fullPath)); + // evaluate what is at the path we're being asked to watch + try { + const follow = this.options.followSymlinks; + const stats = await (follow ? this.stat(path) : this.lstat(path)); // TODO: this creates more calls when done inside of a directory + if (this.closed) return; + if (this.isIgnored(path, stats)) { + this.emitReady(); + return false; + } + const _handleDir = async (closerPath, dir, target, realpath) => { + const parentDir = this.getWatchedDir(sysPath.dirname(dir)); + const tracked = parentDir.has(sysPath.basename(dir)); + if (!(initialAdd && this.options.ignoreInitial) && !target && !tracked) + this._emit('addDir', dir, stats); + const handleRead = ( + directory, + initialAdd, + throttler = this.throttle('readdir', directory, 1000) + ) => { + directory = sysPath.join(directory, ''); // Normalize the directory name on Windows + if (!throttler) return; + if (this.closed) return; + const previous = this.getWatchedDir(path); + const current = new Set(); + // eslint-disable-next-line + return new Promise(async (resolve) => { + try { + const files = await this.readdir(directory); + const all = files.map(async (dirent) => { + try { + const basename = dirent.name; + const fullPath = sysPath.resolve(sysPath.join(directory, basename)); + let stats: Stats | undefined; + if (this.closed) return; + // TODO: this is what ignoreDir && ignorePath did. Why don't we check dir && symlink perms? + if (this.isIgnored(entryPath(fullPath))) return; + if ( + !dirent.isDirectory() && + !dirent.isSymbolicLink() && + !this.options.ignorePermissionErrors + ) { + stats = await this.lstat(fullPath); + if (!stats) return; + if (!hasReadPermissions(stats)) return; + } + if (this.closed) return; + const item = sysPath.relative(sysPath.resolve(directory), fullPath); + const path = sysPath.join(directory, item); // looks like absolute path? + current.add(item); + if (dirent.isSymbolicLink()) { + if (this.closed) return; + if (!follow) { + const dir = this.getWatchedDir(directory); + // watch symlink directly (don't follow) and detect changes + this.readyCount++; + let linkPath; + try { + linkPath = await fsrealpath(path); + } catch (e) { + this.emitReady(); + return; + } + if (this.closed) return; + if (dir.has(item)) { + if (this.symlinkPaths.get(fullPath) !== linkPath) { + this.symlinkPaths.set(fullPath, linkPath); + this._emit('change', path, stats); + } + } else { + dir.add(item); + this.symlinkPaths.set(fullPath, linkPath); + this._emit('add', path, stats); + } + this.emitReady(); + return; + } + // don't follow the same symlink more than once + if (this.symlinkPaths.has(fullPath)) return; + this.symlinkPaths.set(fullPath, true); + } + if (this.closed) return; + // Files which are present in current directory snapshot + // but absent from previous one, are added to watch list and + // emit `add` event. + if (item === target || (!target && !previous.has(item))) { + this.readyCount++; + this.addToNodeFs( + // ensure relativeness of path is preserved in case of watcher reuse + sysPath.join(dir, sysPath.relative(dir, path)), + initialAdd, + watchPath, + depth + 1 + ); // wh re-used only here + } + } catch (err) { + if (!NORMAL_FLOW_ERRORS.has(err.code)) this.handleError(err); + } + }); + await Promise.all(all); + } catch (err) { + if (!NORMAL_FLOW_ERRORS.has(err.code)) { + this.handleError(err); + return; // promise never resolves? + } + } finally { + resolve(undefined); + } + // End, only if everything is ok? will create promise which will never resolve! + const wasThrottled = throttler ? (throttler as any).clear() : false; + // Files which are absent in current directory snapshot, + // but present in previous one, emit `remove` event + // and are removed from @watched[directory]. + for (const item of previous) { + if (item === directory || current.has(item)) continue; + this.remove(directory, item); + } + // one more time for any missed in case changes came in extremely quickly + if (wasThrottled) handleRead(directory, false, throttler); + }); + }; + // ensure dir is tracked (harmless if redundant) + parentDir.add(sysPath.basename(dir)); + this.getWatchedDir(dir); + const maxDepth = this.options.depth; + if ((maxDepth == null || depth <= maxDepth) && !this.symlinkPaths.has(realpath)) { + if (!target) { + // Initial read (before watch) + await handleRead(dir, initialAdd); + if (this.closed) return; + } + this.addWatcher(closerPath, dir, (dirPath, stats) => { + if (stats && stats.mtimeMs === 0) return; // if current directory is removed, do nothing + handleRead(dirPath, false); + }); + } + }; + if (stats.isDirectory()) { + const targetPath = follow ? await fsrealpath(path) : path; + if (this.closed) return; + await _handleDir(path, path, target, targetPath); + if (this.closed) return; + // preserve this symlink's target path + const absPath = sysPath.resolve(path); + if (absPath !== targetPath && targetPath !== undefined) + this.symlinkPaths.set(absPath, targetPath); + } else if (stats.isSymbolicLink()) { + // Symlinks doesn't emit any event, only parent directory does + const targetPath = follow ? await fsrealpath(path) : path; + if (this.closed) return; + const parent = sysPath.dirname(path); + this.getWatchedDir(parent).add(path); + this._emit('add', path, stats); + await _handleDir(path, parent, path, targetPath); + if (this.closed) return; + // preserve this symlink's target path + if (targetPath !== undefined) this.symlinkPaths.set(sysPath.resolve(path), targetPath); + } else { + const handleFile = () => { + if (this.closed) return; + const dirname = sysPath.dirname(path); + const basename = sysPath.basename(path); + const parent = this.getWatchedDir(dirname); + // stats is always present + let prevStats: Stats = stats; + // if the file is already being watched, do nothing + if (parent.has(basename)) return; + const file = path; + const listener = async (path: Path, newStats: Stats) => { + if (!this.throttle('watch', file, 5)) return; + if (!newStats || newStats.mtimeMs === 0) { + try { + const newStats = await this.stat(file); + if (this.closed) return; + // This is broken: we cannot check atime at all, it can be empty (noatime), it can be slowly updated (relatime), + // modification can be done without read (no atime changed). + // Correct way: + // oldmtime !== newmtime -> change + // oldsize !== size -> change: this way we can catch change, even when mtime is identical + // Check that `change` event was not fired because of changed only accessTime. + const at = newStats.atimeMs; + const mt = newStats.mtimeMs; + if (!at || at <= mt || mt !== prevStats.mtimeMs) + this._emit('change', file, newStats); + // When inode is changed, we need to re-add file with same path + if ((isMacos || isLinux) && prevStats.ino !== newStats.ino) { + this.closeWatcher(path); + this.addWatcher(path, file, listener); // TODO: read file? looks ugly + } + prevStats = newStats; + } catch (error) { + // Fix issues where mtime is null but file is still present + this.remove(dirname, basename); + } + // Add is about to be emitted if file not already tracked in parent + } else if (parent.has(basename)) { + // Check that change event was not fired because of changed only accessTime. + const at = newStats.atimeMs; + const mt = newStats.mtimeMs; + if (!at || at <= mt || mt !== prevStats.mtimeMs) this._emit('change', file, newStats); + prevStats = newStats; + } + }; + // Kick off the watcher + this.addWatcher(path, file, listener); + // Emit an add event if we're supposed to + if (!(initialAdd && this.options.ignoreInitial) && !this.isIgnored(file)) { + if (!this.throttle('add', file, 0)) return; + this._emit('add', file, stats); + } + }; + handleFile(); + } + + this.emitReady(); + return false; + } catch (error) { + this.emitReady(); + return path; + } + } + + private emitWithAll(event: EventName, args: EmitArgs) { + this.emit(...args); + if (event !== 'error') this.emit('all', ...args); + } + // Common helpers + // -------------- + /** + * Normalize and emit events. + * Calling _emit DOES NOT MEAN emit() would be called! + * @param {EventName} event Type of event + * @param {Path} path File or directory path + * @returns the error if defined, otherwise the value of the FSWatcher instance's `closed` flag + */ + private async _emit(event: EventName, path: Path, stats?: Stats) { + if (this.closed) return; + const opts = this.options; + if (isWindows) path = sysPath.normalize(path); + if (opts.cwd) path = sysPath.relative(opts.cwd, path); + const args: EmitArgs = [event, path]; + if (stats !== undefined) args.push(stats); + const awf = opts.awaitWriteFinish; + let pw; + if (awf && (pw = this.pendingWrites.get(path))) { + pw.lastChange = Date.now(); + return this; + } + if (opts.atomic) { + if (event === 'unlink') { + this.pendingUnlinks.set(path, args); + setTimeout( + () => { + this.pendingUnlinks.forEach((entry: EmitArgs, path: Path) => { + this.emit(...entry); + this.emit('all', ...entry); + this.pendingUnlinks.delete(path); + }); + }, + typeof opts.atomic === 'number' ? opts.atomic : 100 // TODO: defaults should be in constructor + ); + return this; + } + if (event === 'add' && this.pendingUnlinks.has(path)) { + event = args[0] = 'change'; + this.pendingUnlinks.delete(path); + } + } + const fullPath = opts.cwd ? sysPath.join(opts.cwd, path) : path; + if ( + opts.alwaysStat && + stats === undefined && + (event === 'add' || event === 'addDir' || event === 'change') + ) { + let stats; + try { + stats = await this.stat(fullPath); + } catch (err) { + // do nothing + } + // Suppress event when fs_stat fails, to avoid sending undefined 'stat' + if (!stats || this.closed) return; + args.push(stats); + } + if ( + awf && + typeof awf === 'object' && + (event === 'add' || event === 'change') && + this.readyEmitted + ) { + const threshold = awf.stabilityThreshold; + if (!this.pendingWrites.has(path)) { + let timeoutHandler; + this.pendingWrites.set(path, { + lastChange: Date.now(), + cancelWait: () => { + this.pendingWrites.delete(path); + clearTimeout(timeoutHandler); + return event; + }, + }); + // TODO: cleanup + const awaitWriteFinish = async (prevStat?: Stats) => { + try { + const curStat = await this.stat(fullPath); + if (!this.pendingWrites.has(path)) return; + const now = Date.now(); + if (prevStat && curStat.size !== prevStat.size) + this.pendingWrites.get(path).lastChange = now; + const pw = this.pendingWrites.get(path); + const df = now - pw.lastChange; + if (df >= threshold) { + this.pendingWrites.delete(path); + this.emitWithAll(event, [event, path, curStat]); + } else timeoutHandler = setTimeout(awaitWriteFinish, awf.pollInterval, curStat); + } catch (err) { + if (err && err.code !== 'ENOENT') this.emitWithAll(event, ['error', err as any]); + } + }; + timeoutHandler = setTimeout(awaitWriteFinish, awf.pollInterval); + } + return this; + } + if (event === 'change' && !this.throttle('change', path, 50)) return this; + this.emitWithAll(event, args); + return this; + } + /** + * Common handler for errors + */ + private handleError(error: Error & { code?: string }, path?: Path) { + const code = error && error.code; + if ( + error && + code !== 'ENOENT' && + code !== 'ENOTDIR' && + (!this.options.ignorePermissionErrors || (code !== 'EPERM' && code !== 'EACCES')) + ) { + // TODO: this problem still exists in node v18 + windows 11 + // supressing error doesn't actually fix it, since watcher is unusable after that + // Worth fixing later + // Workaround for https://github.com/joyent/node/issues/4337 + if (isWindows && error.code === 'EPERM') { + (async () => { + if (await this.canOpen(path)) this.emit('error', error); + })(); + return; + } + this.emit('error', error); + } + } + /** + * Determines whether user has asked to ignore this path. + */ + private isIgnored(path: Path, stats?: Stats) { + // Temporary files for editors with atomic write. This probably should be handled separately. + const DOT_RE = /\..*\.(sw[px])$|~$|\.subl.*\.tmp/; + if (this.options.atomic && DOT_RE.test(path)) return true; + if (!this.userIgnored) { + const list: Matcher[] = [...this.ignoredPaths, ...(this.options.ignored || [])].map((path) => + typeof path === 'string' ? this.normalizePath(path) : path + ); + // Early cache for matchers. + const patterns = list.map((matcher) => { + if (typeof matcher === 'function') return matcher; + if (typeof matcher === 'string') return (string) => matcher === string; + if (matcher instanceof RegExp) return (string) => matcher.test(string); + // TODO: remove / refactor + if (typeof matcher === 'object' && matcher !== null) { + return (string) => { + if (matcher.path === string) return true; + if (matcher.recursive) { + const relative = sysPath.relative(matcher.path, string); + if (!relative) return false; + return !relative.startsWith('..') && !sysPath.isAbsolute(relative); + } + return false; + }; + } + return () => false; + }); + this.userIgnored = (path: string, stats?: Stats): boolean => { + path = this.normalizePath(path); + for (const pattern of patterns) if (pattern(path, stats)) return true; + return false; + }; + } + return this.userIgnored(path, stats); + } + /** + * Handles emitting unlink events for + * files and directories, and via recursion, for + * files and directories within directories that are unlinked + * @param directory within which the following item is located + * @param item base path of item/directory + */ + private remove(directory: string, item: string, isDirectory?: boolean) { + // When a directory is deleted, get its paths for recursive deletion + // and cleaning of watched object. + // When not a directory, nestedDirectoryChildren will be empty. + const path = sysPath.join(directory, item); + const fullPath = sysPath.resolve(path); + isDirectory = + isDirectory != null ? isDirectory : this.watched.has(path) || this.watched.has(fullPath); + // prevent duplicate handling in case of arriving here nearly simultaneously + // via multiple paths (such as _handleFile and _handleDir) + if (!this.throttle('remove', path, 100)) return; + // if the only watched file is removed, watch for its return + if (!isDirectory && this.watched.size === 1) this.add(directory, item, true); + // This will create a new entry in the watched object in either case + // so we got to do the directory check beforehand + const wp = this.getWatchedDir(path); + // Recursively remove children directories / files. + wp.forEach((nested) => this.remove(path, nested)); + // Check if item was on the watched list and remove it + const parent = this.getWatchedDir(directory); + const wasTracked = parent.has(item); + parent.delete(item); + this.canOpenDir(directory); + // Fixes issue #1042 -> Relative paths were detected and added as symlinks + // (https://github.com/paulmillr/chokidar/blob/e1753ddbc9571bdc33b4a4af172d52cb6e611c10/lib/nodefs-handler.js#L612), + // but never removed from the map in case the path was deleted. + // This leads to an incorrect state if the path was recreated: + // https://github.com/paulmillr/chokidar/blob/e1753ddbc9571bdc33b4a4af172d52cb6e611c10/lib/nodefs-handler.js#L553 + if (this.symlinkPaths.has(fullPath)) this.symlinkPaths.delete(fullPath); + // If we wait for this file to be fully written, cancel the wait. + let relPath = path; + if (this.options.cwd) relPath = sysPath.relative(this.options.cwd, path); + if (this.options.awaitWriteFinish && this.pendingWrites.has(relPath)) { + const event = this.pendingWrites.get(relPath).cancelWait(); + if (event === 'add') return; + } + // The Entry will either be a directory that just got removed + // or a bogus entry to a file, in either case we have to remove it + this.watched.delete(path); + this.watched.delete(fullPath); + const eventName: EventName = isDirectory ? 'unlinkDir' : 'unlink'; + if (wasTracked && !this.isIgnored(path)) this._emit(eventName, path); + // Avoid conflicts if we later create another file with the same name + this.closeWatcher(path); + } + + // Public API + /** + * Adds paths to be watched on an existing FSWatcher instance + * @param {Path|Array} paths_ + * @param {String=} _origAdd private; for handling non-existent paths to be watched + * @param {Boolean=} _internal private; indicates a non-user add + * @returns {FSWatcher} for chaining + */ + add(paths_: Path | Path[], _origAdd?: string, _internal?: boolean) { + this.closed = false; + const paths = this.normalizePaths(paths_); + paths.forEach((matcher) => { + this.ignoredPaths.delete(matcher); + // now find any matcher objects with the matcher as path + if (typeof matcher === 'string') { + for (const ignored of this.ignoredPaths) { + // TODO (43081j): make this more efficient. + // probably just make a `this._ignoredDirectories` or some + // such thing. + if (isMatcherObject(ignored) && ignored.path === matcher) + this.ignoredPaths.delete(ignored); + } + } + }); + this.userIgnored = undefined; + if (!this.readyCount) this.readyCount = 0; + this.readyCount += paths.length; + Promise.all( + paths.map(async (path) => { + const res = await this.addToNodeFs(path, !_internal, undefined, 0, _origAdd); + if (this.closed) return; + if (res) { + this.emitReady(); + this.add(sysPath.dirname(res), sysPath.basename(_origAdd || res)); + } + return res; + }) + ); + return this; + } + /** + * Close watchers or start ignoring events from specified paths. + * @param {Path|Array} paths - string or array of strings, file/directory paths + * @returns {FSWatcher} for chaining + */ + unwatch(paths: Path | Path[]) { + if (this.closed) return this; + paths = flatten(arrify(paths)); + //paths = this.normalizePaths(paths); + for (let path of paths) { + const { cwd } = this.options; + // If path relative and + if (!sysPath.isAbsolute(path) && !this.closers.has(path)) { + if (cwd) path = sysPath.join(cwd, path); + path = sysPath.resolve(path); + } + this.closeWatcher(path); + if (isMatcherObject(path)) { + // return early if we already have a deeply equal matcher object + for (const ignored of this.ignoredPaths) { + if ( + isMatcherObject(ignored) && + ignored.path === path.path && + ignored.recursive === path.recursive + ) { + continue; + } + } + } + this.ignoredPaths.add(path); + this.userIgnored = undefined; // reset the cached userIgnored fn + } + return this; + } + /** + * Expose list of watched paths + * @returns {Record} + */ + getWatched() { + const watchList = {}; + this.watched.forEach((entry, dir) => { + const key = this.options.cwd ? sysPath.relative(this.options.cwd, dir) : dir; + watchList[key || '.'] = Array.from(entry).sort(); + }); + return watchList; + } + /** + * Close watchers and remove all listeners from watched paths. + */ + close() { + if (this.closed) return this.closePromise; + this.closed = true; + // Memory management. + this.removeAllListeners(); + const closers = []; + this.closers.forEach((closerList) => + closerList.forEach((closer) => { + const promise = closer(); + if (promise instanceof Promise) closers.push(promise); + }) + ); + this.userIgnored = undefined; + // this allows to re-start? + this.readyCount = 0; + this.readyEmitted = false; + this.watched.forEach((dirent) => dirent.clear()); + ['closers', 'watched', 'symlinkPaths', 'throttled'].forEach((key) => { + this[key].clear(); + }); + this.metrics = {}; + this.closePromise = closers.length + ? Promise.all(closers).then(() => undefined) + : Promise.resolve(); + return this.closePromise; + } +} + +// Public API + +/** + * Instantiates watcher with paths to be tracked. + * @param paths file/directory paths and/or globs + * @param options chokidar opts + * @returns an instance of FSWatcher for chaining. + */ +export const watch = (paths: Path | Path[], options: ChokidarOptions) => { + const watcher = new FSWatcher(options); + watcher.add(paths); + return watcher; +}; + +export default { watch, FSWatcher }; diff --git a/test-v4.mjs b/test-v4.mjs new file mode 100644 index 00000000..a87857bc --- /dev/null +++ b/test-v4.mjs @@ -0,0 +1,2138 @@ +import fs from 'node:fs'; +import sysPath from 'node:path'; +import { describe, it, before, after, beforeEach, afterEach } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import childProcess from 'node:child_process'; +import chai from 'chai'; +import { rimraf } from 'rimraf'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import upath from 'upath'; + +import chokidar from './lib/v4.js'; +import { isWindows, isMacos, isIBMi, EV } from './lib/v4.js'; + +import { URL } from 'url'; // in Browser, the URL in native accessible on window + +const __filename = fileURLToPath(new URL('', import.meta.url)); +// Will contain trailing slash +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +const { expect } = chai; +chai.use(sinonChai); +chai.should(); + +const exec = promisify(childProcess.exec); +const write = promisify(fs.writeFile); +const fs_symlink = promisify(fs.symlink); +const fs_rename = promisify(fs.rename); +const fs_mkdir = promisify(fs.mkdir); +const fs_rmdir = promisify(fs.rmdir); +const fs_unlink = promisify(fs.unlink); + +const FIXTURES_PATH_REL = 'test-fixtures'; +const FIXTURES_PATH = sysPath.join(__dirname, FIXTURES_PATH_REL); +const allWatchers = []; +const PERM_ARR = 0o755; // rwe, r+e, r+e +const TEST_TIMEOUT = 8000; +let subdirId = 0; +let options; +let currentDir; +let slowerDelay; + +// spyOnReady +const aspy = (watcher, eventName, spy = null, noStat = false) => { + if (typeof eventName !== 'string') { + throw new TypeError('aspy: eventName must be a String'); + } + if (spy == null) spy = sinon.spy(); + return new Promise((resolve, reject) => { + const handler = noStat + ? eventName === EV.ALL + ? (event, path) => spy(event, path) + : (path) => spy(path) + : spy; + const timeout = setTimeout(() => { + reject(new Error('timeout')); + }, TEST_TIMEOUT); + watcher.on(EV.ERROR, (...args) => { + clearTimeout(timeout); + reject(...args); + }); + watcher.on(EV.READY, () => { + clearTimeout(timeout); + resolve(spy); + }); + watcher.on(eventName, handler); + }); +}; + +const waitForWatcher = (watcher) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('timeout')); + }, TEST_TIMEOUT); + watcher.on(EV.ERROR, (...args) => { + clearTimeout(timeout); + reject(...args); + }); + watcher.on(EV.READY, (...args) => { + clearTimeout(timeout); + resolve(...args); + }); + }); +}; + +const delay = async (time) => { + return new Promise((resolve) => { + const timer = time || slowerDelay || 20; + setTimeout(resolve, timer); + }); +}; + +const getFixturePath = (subPath) => { + const subd = (subdirId && subdirId.toString()) || ''; + return sysPath.join(FIXTURES_PATH, subd, subPath); +}; +const getGlobPath = (subPath) => { + const subd = (subdirId && subdirId.toString()) || ''; + return upath.join(FIXTURES_PATH, subd, subPath); +}; +currentDir = getFixturePath(''); + +const chokidar_watch = (path = currentDir, opts = options) => { + const wt = chokidar.watch(path, opts); + allWatchers.push(wt); + return wt; +}; + +const waitFor = async (spies) => { + if (spies.length === 0) throw new TypeError('SPies zero'); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('timeout')); + }, TEST_TIMEOUT); + const isSpyReady = (spy) => { + if (Array.isArray(spy)) { + return spy[0].callCount >= spy[1]; + } + return spy.callCount >= 1; + }; + const checkSpiesReady = () => { + if (spies.every(isSpyReady)) { + clearTimeout(timeout); + resolve(); + } else { + setTimeout(checkSpiesReady, 20); + } + }; + checkSpiesReady(); + }); +}; + +const waitForEvents = (watcher, count) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('timeout')); + }, TEST_TIMEOUT); + const events = []; + const handler = (event, path) => { + events.push(`[ALL] ${event}: ${path}`); + + if (events.length === count) { + watcher.off('all', handler); + clearTimeout(timeout); + resolve(events); + } + }; + + watcher.on('all', handler); + }); +}; + +const dateNow = () => Date.now().toString(); + +const runTests = (baseopts) => { + let macosFswatch; + let win32Polling; + + baseopts.persistent = true; + + before(() => { + // flags for bypassing special-case test failures on CI + macosFswatch = isMacos && !baseopts.usePolling; + win32Polling = isWindows && baseopts.usePolling; + slowerDelay = macosFswatch ? 100 : undefined; + }); + + beforeEach(function clean() { + options = {}; + Object.keys(baseopts).forEach((key) => { + options[key] = baseopts[key]; + }); + }); + + describe('watch a directory', () => { + let readySpy, rawSpy, watcher, watcher2; + beforeEach(async () => { + options.ignoreInitial = true; + options.alwaysStat = true; + readySpy = sinon.spy(function readySpy() {}); + rawSpy = sinon.spy(function rawSpy() {}); + watcher = chokidar_watch().on(EV.READY, readySpy).on(EV.RAW, rawSpy); + await waitForWatcher(watcher); + }); + afterEach(async () => { + await waitFor([readySpy]); + await watcher.close(); + readySpy.should.have.been.calledOnce; + readySpy = undefined; + rawSpy = undefined; + }); + it('should produce an instance of chokidar.FSWatcher', () => { + watcher.should.be.an.instanceof(chokidar.FSWatcher); + }); + it('should expose public API methods', () => { + watcher.on.should.be.a('function'); + watcher.emit.should.be.a('function'); + watcher.add.should.be.a('function'); + watcher.close.should.be.a('function'); + watcher.getWatched.should.be.a('function'); + }); + it('should emit `add` event when file was added', async () => { + const testPath = getFixturePath('add.txt'); + const spy = sinon.spy(function addSpy() {}); + watcher.on(EV.ADD, spy); + await delay(); + await write(testPath, dateNow()); + await waitFor([spy]); + spy.should.have.been.calledOnce; + spy.should.have.been.calledWith(testPath); + expect(spy.args[0][1]).to.be.ok; // stats + rawSpy.should.have.been.called; + }); + it('should emit nine `add` events when nine files were added in one directory', async () => { + const paths = []; + for (let i = 1; i <= 9; i++) { + paths.push(getFixturePath(`add${i}.txt`)); + } + + const spy = sinon.spy(); + watcher.on(EV.ADD, (path) => { + spy(path); + }); + + write(paths[0], dateNow()); + write(paths[1], dateNow()); + write(paths[2], dateNow()); + write(paths[3], dateNow()); + write(paths[4], dateNow()); + await delay(100); + + write(paths[5], dateNow()); + write(paths[6], dateNow()); + + await delay(150); + write(paths[7], dateNow()); + write(paths[8], dateNow()); + + await waitFor([[spy, 4]]); + + await delay(1000); + await waitFor([[spy, 9]]); + paths.forEach((path) => { + spy.should.have.been.calledWith(path); + }); + }); + it('should emit thirtythree `add` events when thirtythree files were added in nine directories', async () => { + await watcher.close(); + + const test1Path = getFixturePath('add1.txt'); + const testb1Path = getFixturePath('b/add1.txt'); + const testc1Path = getFixturePath('c/add1.txt'); + const testd1Path = getFixturePath('d/add1.txt'); + const teste1Path = getFixturePath('e/add1.txt'); + const testf1Path = getFixturePath('f/add1.txt'); + const testg1Path = getFixturePath('g/add1.txt'); + const testh1Path = getFixturePath('h/add1.txt'); + const testi1Path = getFixturePath('i/add1.txt'); + const test2Path = getFixturePath('add2.txt'); + const testb2Path = getFixturePath('b/add2.txt'); + const testc2Path = getFixturePath('c/add2.txt'); + const test3Path = getFixturePath('add3.txt'); + const testb3Path = getFixturePath('b/add3.txt'); + const testc3Path = getFixturePath('c/add3.txt'); + const test4Path = getFixturePath('add4.txt'); + const testb4Path = getFixturePath('b/add4.txt'); + const testc4Path = getFixturePath('c/add4.txt'); + const test5Path = getFixturePath('add5.txt'); + const testb5Path = getFixturePath('b/add5.txt'); + const testc5Path = getFixturePath('c/add5.txt'); + const test6Path = getFixturePath('add6.txt'); + const testb6Path = getFixturePath('b/add6.txt'); + const testc6Path = getFixturePath('c/add6.txt'); + const test7Path = getFixturePath('add7.txt'); + const testb7Path = getFixturePath('b/add7.txt'); + const testc7Path = getFixturePath('c/add7.txt'); + const test8Path = getFixturePath('add8.txt'); + const testb8Path = getFixturePath('b/add8.txt'); + const testc8Path = getFixturePath('c/add8.txt'); + const test9Path = getFixturePath('add9.txt'); + const testb9Path = getFixturePath('b/add9.txt'); + const testc9Path = getFixturePath('c/add9.txt'); + fs.mkdirSync(getFixturePath('b'), PERM_ARR); + fs.mkdirSync(getFixturePath('c'), PERM_ARR); + fs.mkdirSync(getFixturePath('d'), PERM_ARR); + fs.mkdirSync(getFixturePath('e'), PERM_ARR); + fs.mkdirSync(getFixturePath('f'), PERM_ARR); + fs.mkdirSync(getFixturePath('g'), PERM_ARR); + fs.mkdirSync(getFixturePath('h'), PERM_ARR); + fs.mkdirSync(getFixturePath('i'), PERM_ARR); + + await delay(); + + readySpy.resetHistory(); + watcher2 = chokidar_watch().on(EV.READY, readySpy).on(EV.RAW, rawSpy); + const spy = await aspy(watcher2, EV.ADD, null, true); + + const filesToWrite = [ + test1Path, + test2Path, + test3Path, + test4Path, + test5Path, + test6Path, + test7Path, + test8Path, + test9Path, + testb1Path, + testb2Path, + testb3Path, + testb4Path, + testb5Path, + testb6Path, + testb7Path, + testb8Path, + testb9Path, + testc1Path, + testc2Path, + testc3Path, + testc4Path, + testc5Path, + testc6Path, + testc7Path, + testc8Path, + testc9Path, + testd1Path, + teste1Path, + testf1Path, + testg1Path, + testh1Path, + testi1Path, + ]; + + let currentCallCount = 0; + + for (const fileToWrite of filesToWrite) { + await write(fileToWrite, dateNow()); + await waitFor([[spy, ++currentCallCount]]); + } + + spy.should.have.been.calledWith(test1Path); + spy.should.have.been.calledWith(test2Path); + spy.should.have.been.calledWith(test3Path); + spy.should.have.been.calledWith(test4Path); + spy.should.have.been.calledWith(test5Path); + spy.should.have.been.calledWith(test6Path); + spy.should.have.been.calledWith(test7Path); + spy.should.have.been.calledWith(test8Path); + spy.should.have.been.calledWith(test9Path); + spy.should.have.been.calledWith(testb1Path); + spy.should.have.been.calledWith(testb2Path); + spy.should.have.been.calledWith(testb3Path); + spy.should.have.been.calledWith(testb4Path); + spy.should.have.been.calledWith(testb5Path); + spy.should.have.been.calledWith(testb6Path); + spy.should.have.been.calledWith(testb7Path); + spy.should.have.been.calledWith(testb8Path); + spy.should.have.been.calledWith(testb9Path); + spy.should.have.been.calledWith(testc1Path); + spy.should.have.been.calledWith(testc2Path); + spy.should.have.been.calledWith(testc3Path); + spy.should.have.been.calledWith(testc4Path); + spy.should.have.been.calledWith(testc5Path); + spy.should.have.been.calledWith(testc6Path); + spy.should.have.been.calledWith(testc7Path); + spy.should.have.been.calledWith(testc8Path); + spy.should.have.been.calledWith(testc9Path); + spy.should.have.been.calledWith(testd1Path); + spy.should.have.been.calledWith(teste1Path); + spy.should.have.been.calledWith(testf1Path); + spy.should.have.been.calledWith(testg1Path); + spy.should.have.been.calledWith(testh1Path); + spy.should.have.been.calledWith(testi1Path); + }); + it('should emit `addDir` event when directory was added', async () => { + const testDir = getFixturePath('subdir'); + const spy = sinon.spy(function addDirSpy() {}); + watcher.on(EV.ADD_DIR, spy); + spy.should.not.have.been.called; + await fs_mkdir(testDir, PERM_ARR); + await waitFor([spy]); + spy.should.have.been.calledOnce; + spy.should.have.been.calledWith(testDir); + expect(spy.args[0][1]).to.be.ok; // stats + rawSpy.should.have.been.called; + }); + it('should emit `change` event when file was changed', async () => { + const testPath = getFixturePath('change.txt'); + const spy = sinon.spy(function changeSpy() {}); + watcher.on(EV.CHANGE, spy); + spy.should.not.have.been.called; + await write(testPath, dateNow()); + await waitFor([spy]); + spy.should.have.been.calledWith(testPath); + expect(spy.args[0][1]).to.be.ok; // stats + rawSpy.should.have.been.called; + spy.should.have.been.calledOnce; + }); + it('should emit `unlink` event when file was removed', async () => { + const testPath = getFixturePath('unlink.txt'); + const spy = sinon.spy(function unlinkSpy() {}); + watcher.on(EV.UNLINK, spy); + spy.should.not.have.been.called; + await fs_unlink(testPath); + await waitFor([spy]); + spy.should.have.been.calledWith(testPath); + expect(spy.args[0][1]).to.not.be.ok; // no stats + rawSpy.should.have.been.called; + spy.should.have.been.calledOnce; + }); + it('should emit `unlinkDir` event when a directory was removed', async () => { + const testDir = getFixturePath('subdir'); + const spy = sinon.spy(function unlinkDirSpy() {}); + + await fs_mkdir(testDir, PERM_ARR); + await delay(300); + watcher.on(EV.UNLINK_DIR, spy); + + await fs_rmdir(testDir); + await waitFor([spy]); + spy.should.have.been.calledWith(testDir); + expect(spy.args[0][1]).to.not.be.ok; // no stats + rawSpy.should.have.been.called; + spy.should.have.been.calledOnce; + }); + it('should emit two `unlinkDir` event when two nested directories were removed', async () => { + const testDir = getFixturePath('subdir'); + const testDir2 = getFixturePath('subdir/subdir2'); + const testDir3 = getFixturePath('subdir/subdir2/subdir3'); + const spy = sinon.spy(function unlinkDirSpy() {}); + + await fs_mkdir(testDir, PERM_ARR); + await fs_mkdir(testDir2, PERM_ARR); + await fs_mkdir(testDir3, PERM_ARR); + await delay(300); + + watcher.on(EV.UNLINK_DIR, spy); + + await rimraf(testDir2); + await waitFor([[spy, 2]]); + + spy.should.have.been.calledWith(testDir2); + spy.should.have.been.calledWith(testDir3); + expect(spy.args[0][1]).to.not.be.ok; // no stats + rawSpy.should.have.been.called; + spy.should.have.been.calledTwice; + }); + it('should emit `unlink` and `add` events when a file is renamed', async () => { + const unlinkSpy = sinon.spy(function unlink() {}); + const addSpy = sinon.spy(function add() {}); + const testPath = getFixturePath('change.txt'); + const newPath = getFixturePath('moved.txt'); + watcher.on(EV.UNLINK, unlinkSpy).on(EV.ADD, addSpy); + unlinkSpy.should.not.have.been.called; + addSpy.should.not.have.been.called; + + await delay(); + await fs_rename(testPath, newPath); + await waitFor([unlinkSpy, addSpy]); + unlinkSpy.should.have.been.calledWith(testPath); + expect(unlinkSpy.args[0][1]).to.not.be.ok; // no stats + addSpy.should.have.been.calledOnce; + addSpy.should.have.been.calledWith(newPath); + expect(addSpy.args[0][1]).to.be.ok; // stats + rawSpy.should.have.been.called; + if (!macosFswatch) unlinkSpy.should.have.been.calledOnce; + }); + it('should emit `add`, not `change`, when previously deleted file is re-added', async () => { + const unlinkSpy = sinon.spy(function unlink() {}); + const addSpy = sinon.spy(function add() {}); + const changeSpy = sinon.spy(function change() {}); + const testPath = getFixturePath('add.txt'); + watcher.on(EV.UNLINK, unlinkSpy).on(EV.ADD, addSpy).on(EV.CHANGE, changeSpy); + await write(testPath, 'hello'); + await waitFor([[addSpy.withArgs(testPath), 1]]); + unlinkSpy.should.not.have.been.called; + changeSpy.should.not.have.been.called; + await fs_unlink(testPath); + await waitFor([unlinkSpy.withArgs(testPath)]); + unlinkSpy.should.have.been.calledWith(testPath); + + await delay(100); + await write(testPath, dateNow()); + await waitFor([[addSpy.withArgs(testPath), 2]]); + addSpy.should.have.been.calledWith(testPath); + changeSpy.should.not.have.been.called; + expect(addSpy.callCount).to.equal(2); + }); + it('should not emit `unlink` for previously moved files', async () => { + const unlinkSpy = sinon.spy(function unlink() {}); + const testPath = getFixturePath('change.txt'); + const newPath1 = getFixturePath('moved.txt'); + const newPath2 = getFixturePath('moved-again.txt'); + watcher.on(EV.UNLINK, unlinkSpy); + await fs_rename(testPath, newPath1); + + await delay(300); + await fs_rename(newPath1, newPath2); + await waitFor([unlinkSpy.withArgs(newPath1)]); + unlinkSpy.withArgs(testPath).should.have.been.calledOnce; + unlinkSpy.withArgs(newPath1).should.have.been.calledOnce; + unlinkSpy.withArgs(newPath2).should.not.have.been.called; + }); + it('should survive ENOENT for missing subdirectories', async () => { + const testDir = getFixturePath('notadir'); + watcher.add(testDir); + }); + it('should notice when a file appears in a new directory', async () => { + const testDir = getFixturePath('subdir'); + const testPath = getFixturePath('subdir/add.txt'); + const spy = sinon.spy(function addSpy() {}); + watcher.on(EV.ADD, spy); + spy.should.not.have.been.called; + await fs_mkdir(testDir, PERM_ARR); + await write(testPath, dateNow()); + await waitFor([spy]); + spy.should.have.been.calledOnce; + spy.should.have.been.calledWith(testPath); + expect(spy.args[0][1]).to.be.ok; // stats + rawSpy.should.have.been.called; + }); + it('should watch removed and re-added directories', async () => { + const unlinkSpy = sinon.spy(function unlinkSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const parentPath = getFixturePath('subdir2'); + const subPath = getFixturePath('subdir2/subsub'); + watcher.on(EV.UNLINK_DIR, unlinkSpy).on(EV.ADD_DIR, addSpy); + await fs_mkdir(parentPath, PERM_ARR); + + await delay(win32Polling ? 900 : 300); + await fs_rmdir(parentPath); + await waitFor([unlinkSpy.withArgs(parentPath)]); + unlinkSpy.should.have.been.calledWith(parentPath); + await fs_mkdir(parentPath, PERM_ARR); + + await delay(win32Polling ? 2200 : 1200); + await fs_mkdir(subPath, PERM_ARR); + await waitFor([[addSpy, 3]]); + addSpy.should.have.been.calledWith(parentPath); + addSpy.should.have.been.calledWith(subPath); + }); + it('should emit `unlinkDir` and `add` when dir is replaced by file', async () => { + options.ignoreInitial = true; + const unlinkSpy = sinon.spy(function unlinkSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const testPath = getFixturePath('dirFile'); + await fs_mkdir(testPath, PERM_ARR); + await delay(300); + watcher.on(EV.UNLINK_DIR, unlinkSpy).on(EV.ADD, addSpy); + + await fs_rmdir(testPath); + await waitFor([unlinkSpy]); + + await write(testPath, 'file content'); + await waitFor([addSpy]); + + unlinkSpy.should.have.been.calledWith(testPath); + addSpy.should.have.been.calledWith(testPath); + }); + it('should emit `unlink` and `addDir` when file is replaced by dir', async () => { + options.ignoreInitial = true; + const unlinkSpy = sinon.spy(function unlinkSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const testPath = getFixturePath('fileDir'); + await write(testPath, 'file content'); + watcher.on(EV.UNLINK, unlinkSpy).on(EV.ADD_DIR, addSpy); + + await delay(300); + await fs_unlink(testPath); + await delay(300); + await fs_mkdir(testPath, PERM_ARR); + + await waitFor([addSpy, unlinkSpy]); + unlinkSpy.should.have.been.calledWith(testPath); + addSpy.should.have.been.calledWith(testPath); + }); + }); + describe('watch individual files', () => { + it('should emit `ready` when three files were added', async () => { + const readySpy = sinon.spy(function readySpy() {}); + const watcher = chokidar_watch().on(EV.READY, readySpy); + const path1 = getFixturePath('add1.txt'); + const path2 = getFixturePath('add2.txt'); + const path3 = getFixturePath('add3.txt'); + + watcher.add(path1); + watcher.add(path2); + watcher.add(path3); + + await waitForWatcher(watcher); + // callCount is 1 on macOS, 4 on Ubuntu + readySpy.callCount.should.be.greaterThanOrEqual(1); + }); + it('should detect changes', async () => { + const testPath = getFixturePath('change.txt'); + const watcher = chokidar_watch(testPath, options); + const spy = await aspy(watcher, EV.CHANGE); + await write(testPath, dateNow()); + await waitFor([spy]); + spy.should.have.always.been.calledWith(testPath); + }); + it('should detect unlinks', async () => { + const testPath = getFixturePath('unlink.txt'); + const watcher = chokidar_watch(testPath, options); + const spy = await aspy(watcher, EV.UNLINK); + + await delay(); + await fs_unlink(testPath); + await waitFor([spy]); + spy.should.have.been.calledWith(testPath); + }); + it('should detect unlink and re-add', async () => { + options.ignoreInitial = true; + const unlinkSpy = sinon.spy(function unlinkSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const testPath = getFixturePath('unlink.txt'); + const watcher = chokidar_watch([testPath], options) + .on(EV.UNLINK, unlinkSpy) + .on(EV.ADD, addSpy); + await waitForWatcher(watcher); + + await delay(); + await fs_unlink(testPath); + await waitFor([unlinkSpy]); + unlinkSpy.should.have.been.calledWith(testPath); + + await delay(); + await write(testPath, 're-added'); + await waitFor([addSpy]); + addSpy.should.have.been.calledWith(testPath); + }); + + it('should ignore unwatched siblings', async () => { + const testPath = getFixturePath('add.txt'); + const siblingPath = getFixturePath('change.txt'); + const watcher = chokidar_watch(testPath, options); + const spy = await aspy(watcher, EV.ALL); + + await delay(); + await write(siblingPath, dateNow()); + await write(testPath, dateNow()); + await waitFor([spy]); + spy.should.have.always.been.calledWith(EV.ADD, testPath); + }); + + it('should detect safe-edit', async () => { + const testPath = getFixturePath('change.txt'); + const safePath = getFixturePath('tmp.txt'); + await write(testPath, dateNow()); + const watcher = chokidar_watch(testPath, options); + const spy = await aspy(watcher, EV.ALL); + + await delay(); + await write(safePath, dateNow()); + await fs_rename(safePath, testPath); + await delay(300); + await write(safePath, dateNow()); + await fs_rename(safePath, testPath); + await delay(300); + await write(safePath, dateNow()); + await fs_rename(safePath, testPath); + await delay(300); + await waitFor([spy]); + spy.withArgs(EV.CHANGE, testPath).should.have.been.calledThrice; + }); + + // PR 682 is failing. + describe.skip('Skipping gh-682: should detect unlink', () => { + it('should detect unlink while watching a non-existent second file in another directory', async () => { + const testPath = getFixturePath('unlink.txt'); + const otherDirPath = getFixturePath('other-dir'); + const otherPath = getFixturePath('other-dir/other.txt'); + fs.mkdirSync(otherDirPath, PERM_ARR); + const watcher = chokidar_watch([testPath, otherPath], options); + // intentionally for this test don't write fs.writeFileSync(otherPath, 'other'); + const spy = await aspy(watcher, EV.UNLINK); + + await delay(); + await fs_unlink(testPath); + await waitFor([spy]); + spy.should.have.been.calledWith(testPath); + }); + it('should detect unlink and re-add while watching a second file', async () => { + options.ignoreInitial = true; + const unlinkSpy = sinon.spy(function unlinkSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const testPath = getFixturePath('unlink.txt'); + const otherPath = getFixturePath('other.txt'); + fs.writeFileSync(otherPath, 'other'); + const watcher = chokidar_watch([testPath, otherPath], options) + .on(EV.UNLINK, unlinkSpy) + .on(EV.ADD, addSpy); + await waitForWatcher(watcher); + + await delay(); + await fs_unlink(testPath); + await waitFor([unlinkSpy]); + + await delay(); + unlinkSpy.should.have.been.calledWith(testPath); + + await delay(); + write(testPath, 're-added'); + await waitFor([addSpy]); + addSpy.should.have.been.calledWith(testPath); + }); + it('should detect unlink and re-add while watching a non-existent second file in another directory', async () => { + options.ignoreInitial = true; + const unlinkSpy = sinon.spy(function unlinkSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const testPath = getFixturePath('unlink.txt'); + const otherDirPath = getFixturePath('other-dir'); + const otherPath = getFixturePath('other-dir/other.txt'); + fs.mkdirSync(otherDirPath, PERM_ARR); + // intentionally for this test don't write fs.writeFileSync(otherPath, 'other'); + const watcher = chokidar_watch([testPath, otherPath], options) + .on(EV.UNLINK, unlinkSpy) + .on(EV.ADD, addSpy); + await waitForWatcher(watcher); + + await delay(); + await fs_unlink(testPath); + await waitFor([unlinkSpy]); + + await delay(); + unlinkSpy.should.have.been.calledWith(testPath); + + await delay(); + await write(testPath, 're-added'); + await waitFor([addSpy]); + addSpy.should.have.been.calledWith(testPath); + }); + it('should detect unlink and re-add while watching a non-existent second file in the same directory', async () => { + options.ignoreInitial = true; + const unlinkSpy = sinon.spy(function unlinkSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const testPath = getFixturePath('unlink.txt'); + const otherPath = getFixturePath('other.txt'); + // intentionally for this test don't write fs.writeFileSync(otherPath, 'other'); + const watcher = chokidar_watch([testPath, otherPath], options) + .on(EV.UNLINK, unlinkSpy) + .on(EV.ADD, addSpy); + await waitForWatcher(watcher); + + await delay(); + await fs_unlink(testPath); + await waitFor([unlinkSpy]); + + await delay(); + unlinkSpy.should.have.been.calledWith(testPath); + + await delay(); + await write(testPath, 're-added'); + await waitFor([addSpy]); + addSpy.should.have.been.calledWith(testPath); + }); + it('should detect two unlinks and one re-add', async () => { + options.ignoreInitial = true; + const unlinkSpy = sinon.spy(function unlinkSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const testPath = getFixturePath('unlink.txt'); + const otherPath = getFixturePath('other.txt'); + fs.writeFileSync(otherPath, 'other'); + const watcher = chokidar_watch([testPath, otherPath], options) + .on(EV.UNLINK, unlinkSpy) + .on(EV.ADD, addSpy); + await waitForWatcher(watcher); + + await delay(); + await fs_unlink(otherPath); + + await delay(); + await fs_unlink(testPath); + await waitFor([[unlinkSpy, 2]]); + + await delay(); + unlinkSpy.should.have.been.calledWith(otherPath); + unlinkSpy.should.have.been.calledWith(testPath); + + await delay(); + await write(testPath, 're-added'); + await waitFor([addSpy]); + addSpy.should.have.been.calledWith(testPath); + }); + it('should detect unlink and re-add while watching a second file and a non-existent third file', async () => { + options.ignoreInitial = true; + const unlinkSpy = sinon.spy(function unlinkSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const testPath = getFixturePath('unlink.txt'); + const otherPath = getFixturePath('other.txt'); + const other2Path = getFixturePath('other2.txt'); + fs.writeFileSync(otherPath, 'other'); + // intentionally for this test don't write fs.writeFileSync(other2Path, 'other2'); + const watcher = chokidar_watch([testPath, otherPath, other2Path], options) + .on(EV.UNLINK, unlinkSpy) + .on(EV.ADD, addSpy); + await waitForWatcher(watcher); + await delay(); + await fs_unlink(testPath); + + await waitFor([unlinkSpy]); + await delay(); + unlinkSpy.should.have.been.calledWith(testPath); + + await delay(); + await write(testPath, 're-added'); + await waitFor([addSpy]); + addSpy.should.have.been.calledWith(testPath); + }); + }); + }); + describe('renamed directory', () => { + it('should emit `add` for a file in a renamed directory', async () => { + options.ignoreInitial = true; + const testDir = getFixturePath('subdir'); + const testPath = getFixturePath('subdir/add.txt'); + const renamedDir = getFixturePath('subdir-renamed'); + const expectedPath = sysPath.join(renamedDir, 'add.txt'); + await fs_mkdir(testDir, PERM_ARR); + await write(testPath, dateNow()); + const watcher = chokidar_watch(currentDir, options); + const spy = await aspy(watcher, EV.ADD); + + await delay(1000); + await fs_rename(testDir, renamedDir); + await waitFor([spy.withArgs(expectedPath)]); + spy.should.have.been.calledWith(expectedPath); + }); + }); + describe('watch non-existent paths', () => { + it('should watch non-existent file and detect add', async () => { + const testPath = getFixturePath('add.txt'); + const watcher = chokidar_watch(testPath, options); + const spy = await aspy(watcher, EV.ADD); + + await delay(); + await write(testPath, dateNow()); + await waitFor([spy]); + spy.should.have.been.calledWith(testPath); + }); + it('should watch non-existent dir and detect addDir/add', async () => { + const testDir = getFixturePath('subdir'); + const testPath = getFixturePath('subdir/add.txt'); + const watcher = chokidar_watch(testDir, options); + const spy = await aspy(watcher, EV.ALL); + spy.should.not.have.been.called; + + await delay(); + await fs_mkdir(testDir, PERM_ARR); + await waitFor([spy.withArgs(EV.ADD_DIR)]); + await write(testPath, 'hello'); + await waitFor([spy.withArgs(EV.ADD)]); + spy.should.have.been.calledWith(EV.ADD_DIR, testDir); + spy.should.have.been.calledWith(EV.ADD, testPath); + }); + }); + describe('not watch glob patterns', () => { + it('should not confuse glob-like filenames with globs', async () => { + const filePath = getFixturePath('nota[glob].txt'); + await write(filePath, 'b'); + await delay(); + const spy = await aspy(chokidar_watch(), EV.ALL); + spy.should.have.been.calledWith(EV.ADD, filePath); + + await delay(); + await write(filePath, dateNow()); + await waitFor([spy.withArgs(EV.CHANGE, filePath)]); + spy.should.have.been.calledWith(EV.CHANGE, filePath); + }); + it('should treat glob-like directory names as literal directory names when globbing is disabled', async () => { + options.disableGlobbing = true; + const filePath = getFixturePath('nota[glob]/a.txt'); + const watchPath = getFixturePath('nota[glob]'); + const testDir = getFixturePath('nota[glob]'); + const matchingDir = getFixturePath('notag'); + const matchingFile = getFixturePath('notag/b.txt'); + const matchingFile2 = getFixturePath('notal'); + fs.mkdirSync(testDir, PERM_ARR); + fs.writeFileSync(filePath, 'b'); + fs.mkdirSync(matchingDir, PERM_ARR); + fs.writeFileSync(matchingFile, 'c'); + fs.writeFileSync(matchingFile2, 'd'); + const watcher = chokidar_watch(watchPath, options); + const spy = await aspy(watcher, EV.ALL); + + spy.should.have.been.calledWith(EV.ADD, filePath); + spy.should.not.have.been.calledWith(EV.ADD_DIR, matchingDir); + spy.should.not.have.been.calledWith(EV.ADD, matchingFile); + spy.should.not.have.been.calledWith(EV.ADD, matchingFile2); + await delay(); + await write(filePath, dateNow()); + + await waitFor([spy.withArgs(EV.CHANGE, filePath)]); + spy.should.have.been.calledWith(EV.CHANGE, filePath); + }); + it('should treat glob-like filenames as literal filenames', async () => { + options.disableGlobbing = true; + const filePath = getFixturePath('nota[glob]'); + // This isn't using getGlobPath because it isn't treated as a glob + const watchPath = getFixturePath('nota[glob]'); + const matchingDir = getFixturePath('notag'); + const matchingFile = getFixturePath('notag/a.txt'); + const matchingFile2 = getFixturePath('notal'); + fs.writeFileSync(filePath, 'b'); + fs.mkdirSync(matchingDir, PERM_ARR); + fs.writeFileSync(matchingFile, 'c'); + fs.writeFileSync(matchingFile2, 'd'); + const watcher = chokidar_watch(watchPath, options); + const spy = await aspy(watcher, EV.ALL); + + spy.should.have.been.calledWith(EV.ADD, filePath); + spy.should.not.have.been.calledWith(EV.ADD_DIR, matchingDir); + spy.should.not.have.been.calledWith(EV.ADD, matchingFile); + spy.should.not.have.been.calledWith(EV.ADD, matchingFile2); + await delay(); + await write(filePath, dateNow()); + + await waitFor([spy.withArgs(EV.CHANGE, filePath)]); + spy.should.have.been.calledWith(EV.CHANGE, filePath); + }); + }); + describe('watch symlinks', () => { + if (isWindows) return true; + let linkedDir; + beforeEach(async () => { + linkedDir = sysPath.resolve(currentDir, '..', `${subdirId}-link`); + await fs_symlink(currentDir, linkedDir, isWindows ? 'dir' : null); + await fs_mkdir(getFixturePath('subdir'), PERM_ARR); + await write(getFixturePath('subdir/add.txt'), 'b'); + return true; + }); + afterEach(async () => { + await fs_unlink(linkedDir); + return true; + }); + + it('should watch symlinked dirs', async () => { + const dirSpy = sinon.spy(function dirSpy() {}); + const addSpy = sinon.spy(function addSpy() {}); + const watcher = chokidar_watch(linkedDir, options).on(EV.ADD_DIR, dirSpy).on(EV.ADD, addSpy); + await waitForWatcher(watcher); + + dirSpy.should.have.been.calledWith(linkedDir); + addSpy.should.have.been.calledWith(sysPath.join(linkedDir, 'change.txt')); + addSpy.should.have.been.calledWith(sysPath.join(linkedDir, 'unlink.txt')); + }); + it('should watch symlinked files', async () => { + const changePath = getFixturePath('change.txt'); + const linkPath = getFixturePath('link.txt'); + fs.symlinkSync(changePath, linkPath); + const watcher = chokidar_watch(linkPath, options); + const spy = await aspy(watcher, EV.ALL); + + await write(changePath, dateNow()); + await waitFor([spy.withArgs(EV.CHANGE)]); + spy.should.have.been.calledWith(EV.ADD, linkPath); + spy.should.have.been.calledWith(EV.CHANGE, linkPath); + }); + it('should follow symlinked files within a normal dir', async () => { + const changePath = getFixturePath('change.txt'); + const linkPath = getFixturePath('subdir/link.txt'); + fs.symlinkSync(changePath, linkPath); + const watcher = chokidar_watch(getFixturePath('subdir'), options); + const spy = await aspy(watcher, EV.ALL); + + await write(changePath, dateNow()); + await waitFor([spy.withArgs(EV.CHANGE, linkPath)]); + spy.should.have.been.calledWith(EV.ADD, linkPath); + spy.should.have.been.calledWith(EV.CHANGE, linkPath); + }); + it('should watch paths with a symlinked parent', async () => { + const testDir = sysPath.join(linkedDir, 'subdir'); + const testFile = sysPath.join(testDir, 'add.txt'); + const watcher = chokidar_watch(testDir, options); + const spy = await aspy(watcher, EV.ALL); + + spy.should.have.been.calledWith(EV.ADD_DIR, testDir); + spy.should.have.been.calledWith(EV.ADD, testFile); + await write(getFixturePath('subdir/add.txt'), dateNow()); + await waitFor([spy.withArgs(EV.CHANGE)]); + spy.should.have.been.calledWith(EV.CHANGE, testFile); + }); + it('should not recurse indefinitely on circular symlinks', async () => { + await fs_symlink(currentDir, getFixturePath('subdir/circular'), isWindows ? 'dir' : null); + return new Promise((resolve, reject) => { + const watcher = chokidar_watch(); + watcher.on(EV.ERROR, resolve()); + watcher.on( + EV.READY, + reject('The watcher becomes ready, although he watches a circular symlink.') + ); + }); + }); + it('should recognize changes following symlinked dirs', async () => { + const linkedFilePath = sysPath.join(linkedDir, 'change.txt'); + const watcher = chokidar_watch(linkedDir, options); + const spy = await aspy(watcher, EV.CHANGE); + const wa = spy.withArgs(linkedFilePath); + await write(getFixturePath('change.txt'), dateNow()); + await waitFor([wa]); + spy.should.have.been.calledWith(linkedFilePath); + }); + it('should follow newly created symlinks', async () => { + options.ignoreInitial = true; + const watcher = chokidar_watch(); + const spy = await aspy(watcher, EV.ALL); + await delay(); + await fs_symlink(getFixturePath('subdir'), getFixturePath('link'), isWindows ? 'dir' : null); + await waitFor([ + spy.withArgs(EV.ADD, getFixturePath('link/add.txt')), + spy.withArgs(EV.ADD_DIR, getFixturePath('link')), + ]); + spy.should.have.been.calledWith(EV.ADD_DIR, getFixturePath('link')); + spy.should.have.been.calledWith(EV.ADD, getFixturePath('link/add.txt')); + }); + it('should watch symlinks as files when followSymlinks:false', async () => { + options.followSymlinks = false; + const watcher = chokidar_watch(linkedDir, options); + const spy = await aspy(watcher, EV.ALL); + spy.should.not.have.been.calledWith(EV.ADD_DIR); + spy.should.have.been.calledWith(EV.ADD, linkedDir); + spy.should.have.been.calledOnce; + }); + it('should survive ENOENT for missing symlinks when followSymlinks:false', async () => { + options.followSymlinks = false; + const targetDir = getFixturePath('subdir/nonexistent'); + await fs_mkdir(targetDir); + await fs_symlink(targetDir, getFixturePath('subdir/broken'), isWindows ? 'dir' : null); + await fs_rmdir(targetDir); + await delay(); + + const watcher = chokidar_watch(getFixturePath('subdir'), options); + const spy = await aspy(watcher, EV.ALL); + + spy.should.have.been.calledTwice; + spy.should.have.been.calledWith(EV.ADD_DIR, getFixturePath('subdir')); + spy.should.have.been.calledWith(EV.ADD, getFixturePath('subdir/add.txt')); + }); + it('should watch symlinks within a watched dir as files when followSymlinks:false', async () => { + options.followSymlinks = false; + // Create symlink in linkPath + const linkPath = getFixturePath('link'); + fs.symlinkSync(getFixturePath('subdir'), linkPath); + const spy = await aspy(chokidar_watch(), EV.ALL); + await delay(300); + setTimeout( + () => { + fs.writeFileSync(getFixturePath('subdir/add.txt'), dateNow()); + fs.unlinkSync(linkPath); + fs.symlinkSync(getFixturePath('subdir/add.txt'), linkPath); + }, + options.usePolling ? 1200 : 300 + ); + + await delay(300); + await waitFor([spy.withArgs(EV.CHANGE, linkPath)]); + spy.should.not.have.been.calledWith(EV.ADD_DIR, linkPath); + spy.should.not.have.been.calledWith(EV.ADD, getFixturePath('link/add.txt')); + spy.should.have.been.calledWith(EV.ADD, linkPath); + spy.should.have.been.calledWith(EV.CHANGE, linkPath); + }); + it('should not reuse watcher when following a symlink to elsewhere', async () => { + const linkedPath = getFixturePath('outside'); + const linkedFilePath = sysPath.join(linkedPath, 'text.txt'); + const linkPath = getFixturePath('subdir/subsub'); + fs.mkdirSync(linkedPath, PERM_ARR); + fs.writeFileSync(linkedFilePath, 'b'); + fs.symlinkSync(linkedPath, linkPath); + const watcher2 = chokidar_watch(getFixturePath('subdir'), options); + await waitForWatcher(watcher2); + + await delay(options.usePolling ? 900 : undefined); + const watchedPath = getFixturePath('subdir/subsub/text.txt'); + const watcher = chokidar_watch(watchedPath, options); + const spy = await aspy(watcher, EV.ALL); + + await delay(); + await write(linkedFilePath, dateNow()); + await waitFor([spy.withArgs(EV.CHANGE)]); + spy.should.have.been.calledWith(EV.CHANGE, watchedPath); + }); + it('should emit ready event even when broken symlinks are encountered', async () => { + const targetDir = getFixturePath('subdir/nonexistent'); + await fs_mkdir(targetDir); + await fs_symlink(targetDir, getFixturePath('subdir/broken'), isWindows ? 'dir' : null); + await fs_rmdir(targetDir); + const readySpy = sinon.spy(function readySpy() {}); + const watcher = chokidar_watch(getFixturePath('subdir'), options).on(EV.READY, readySpy); + await waitForWatcher(watcher); + readySpy.should.have.been.calledOnce; + }); + }); + describe('watch arrays of paths/globs', () => { + it('should watch all paths in an array', async () => { + const testPath = getFixturePath('change.txt'); + const testDir = getFixturePath('subdir'); + fs.mkdirSync(testDir); + const watcher = chokidar_watch([testDir, testPath], options); + const spy = await aspy(watcher, EV.ALL); + spy.should.have.been.calledWith(EV.ADD, testPath); + spy.should.have.been.calledWith(EV.ADD_DIR, testDir); + spy.should.not.have.been.calledWith(EV.ADD, getFixturePath('unlink.txt')); + await write(testPath, dateNow()); + await waitFor([spy.withArgs(EV.CHANGE)]); + spy.should.have.been.calledWith(EV.CHANGE, testPath); + }); + it('should accommodate nested arrays in input', async () => { + const testPath = getFixturePath('change.txt'); + const testDir = getFixturePath('subdir'); + await fs_mkdir(testDir); + const watcher = chokidar_watch([[testDir], [testPath]], options); + const spy = await aspy(watcher, EV.ALL); + spy.should.have.been.calledWith(EV.ADD, testPath); + spy.should.have.been.calledWith(EV.ADD_DIR, testDir); + spy.should.not.have.been.calledWith(EV.ADD, getFixturePath('unlink.txt')); + await write(testPath, dateNow()); + await waitFor([spy.withArgs(EV.CHANGE)]); + spy.should.have.been.calledWith(EV.CHANGE, testPath); + }); + it('should throw if provided any non-string paths', () => { + expect(chokidar_watch.bind(null, [[currentDir], /notastring/])).to.throw( + TypeError, + /non-string/i + ); + }); + }); + describe('watch options', () => { + describe('ignoreInitial', () => { + describe('false', () => { + beforeEach(() => { + options.ignoreInitial = false; + }); + it('should emit `add` events for preexisting files', async () => { + const watcher = chokidar_watch(currentDir, options); + const spy = await aspy(watcher, EV.ADD); + spy.should.have.been.calledTwice; + }); + it('should emit `addDir` event for watched dir', async () => { + const watcher = chokidar_watch(currentDir, options); + const spy = await aspy(watcher, EV.ADD_DIR); + spy.should.have.been.calledOnce; + spy.should.have.been.calledWith(currentDir); + }); + it('should emit `addDir` events for preexisting dirs', async () => { + await fs_mkdir(getFixturePath('subdir'), PERM_ARR); + await fs_mkdir(getFixturePath('subdir/subsub'), PERM_ARR); + const watcher = chokidar_watch(currentDir, options); + const spy = await aspy(watcher, EV.ADD_DIR); + spy.should.have.been.calledWith(currentDir); + spy.should.have.been.calledWith(getFixturePath('subdir')); + spy.should.have.been.calledWith(getFixturePath('subdir/subsub')); + spy.should.have.been.calledThrice; + }); + }); + describe('true', () => { + beforeEach(() => { + options.ignoreInitial = true; + }); + it('should ignore initial add events', async () => { + const watcher = chokidar_watch(); + const spy = await aspy(watcher, EV.ADD); + await delay(); + spy.should.not.have.been.called; + }); + it('should ignore add events on a subsequent .add()', async () => { + const watcher = chokidar_watch(getFixturePath('subdir'), options); + const spy = await aspy(watcher, EV.ADD); + watcher.add(currentDir); + await delay(1000); + spy.should.not.have.been.called; + }); + it('should notice when a file appears in an empty directory', async () => { + const testDir = getFixturePath('subdir'); + const testPath = getFixturePath('subdir/add.txt'); + const spy = await aspy(chokidar_watch(), EV.ADD); + spy.should.not.have.been.called; + await fs_mkdir(testDir, PERM_ARR); + await write(testPath, dateNow()); + await waitFor([spy]); + spy.should.have.been.calledOnce; + spy.should.have.been.calledWith(testPath); + }); + it('should emit a change on a preexisting file as a change', async () => { + const testPath = getFixturePath('change.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + spy.should.not.have.been.called; + await write(testPath, dateNow()); + await waitFor([spy.withArgs(EV.CHANGE, testPath)]); + spy.should.have.been.calledWith(EV.CHANGE, testPath); + spy.should.not.have.been.calledWith(EV.ADD); + }); + it('should not emit for preexisting dirs when depth is 0', async () => { + options.depth = 0; + const testPath = getFixturePath('add.txt'); + await fs_mkdir(getFixturePath('subdir'), PERM_ARR); + + await delay(200); + const spy = await aspy(chokidar_watch(), EV.ALL); + await write(testPath, dateNow()); + await waitFor([spy]); + + await delay(200); + spy.should.have.been.calledWith(EV.ADD, testPath); + spy.should.not.have.been.calledWith(EV.ADD_DIR); + }); + }); + }); + describe('ignored', () => { + it('should check ignore after stating', async () => { + options.ignored = (path, stats) => { + if (upath.normalizeSafe(path) === upath.normalizeSafe(testDir) || !stats) return false; + return stats.isDirectory(); + }; + const testDir = getFixturePath('subdir'); + fs.mkdirSync(testDir, PERM_ARR); + fs.writeFileSync(sysPath.join(testDir, 'add.txt'), ''); + fs.mkdirSync(sysPath.join(testDir, 'subsub'), PERM_ARR); + fs.writeFileSync(sysPath.join(testDir, 'subsub', 'ab.txt'), ''); + const watcher = chokidar_watch(testDir, options); + const spy = await aspy(watcher, EV.ADD); + spy.should.have.been.calledOnce; + spy.should.have.been.calledWith(sysPath.join(testDir, 'add.txt')); + }); + it('should not choke on an ignored watch path', async () => { + options.ignored = () => { + return true; + }; + await waitForWatcher(chokidar_watch()); + }); + it('should ignore the contents of ignored dirs', async () => { + const testDir = getFixturePath('subdir'); + const testFile = sysPath.join(testDir, 'add.txt'); + options.ignored = testDir; + fs.mkdirSync(testDir, PERM_ARR); + fs.writeFileSync(testFile, 'b'); + const watcher = chokidar_watch(currentDir, options); + const spy = await aspy(watcher, EV.ALL); + + await delay(); + await write(testFile, dateNow()); + + await delay(300); + spy.should.not.have.been.calledWith(EV.ADD_DIR, testDir); + spy.should.not.have.been.calledWith(EV.ADD, testFile); + spy.should.not.have.been.calledWith(EV.CHANGE, testFile); + }); + it('should allow regex/fn ignores', async () => { + options.cwd = currentDir; + options.ignored = /add/; + + fs.writeFileSync(getFixturePath('add.txt'), 'b'); + const watcher = chokidar_watch(currentDir, options); + const spy = await aspy(watcher, EV.ALL); + + await delay(); + await write(getFixturePath('add.txt'), dateNow()); + await write(getFixturePath('change.txt'), dateNow()); + + await waitFor([spy.withArgs(EV.CHANGE, 'change.txt')]); + spy.should.not.have.been.calledWith(EV.ADD, 'add.txt'); + spy.should.not.have.been.calledWith(EV.CHANGE, 'add.txt'); + spy.should.have.been.calledWith(EV.ADD, 'change.txt'); + spy.should.have.been.calledWith(EV.CHANGE, 'change.txt'); + }); + }); + describe('depth', () => { + beforeEach(async () => { + await fs_mkdir(getFixturePath('subdir'), PERM_ARR); + await write(getFixturePath('subdir/add.txt'), 'b'); + await delay(); + await fs_mkdir(getFixturePath('subdir/subsub'), PERM_ARR); + await write(getFixturePath('subdir/subsub/ab.txt'), 'b'); + await delay(); + }); + it('should not recurse if depth is 0', async () => { + options.depth = 0; + const watcher = chokidar_watch(); + const spy = await aspy(watcher, EV.ALL); + await write(getFixturePath('subdir/add.txt'), dateNow()); + await waitFor([[spy, 4]]); + spy.should.have.been.calledWith(EV.ADD_DIR, currentDir); + spy.should.have.been.calledWith(EV.ADD_DIR, getFixturePath('subdir')); + spy.should.have.been.calledWith(EV.ADD, getFixturePath('change.txt')); + spy.should.have.been.calledWith(EV.ADD, getFixturePath('unlink.txt')); + spy.should.not.have.been.calledWith(EV.CHANGE); + if (!macosFswatch) spy.callCount.should.equal(4); + }); + it('should recurse to specified depth', async () => { + options.depth = 1; + const addPath = getFixturePath('subdir/add.txt'); + const changePath = getFixturePath('change.txt'); + const ignoredPath = getFixturePath('subdir/subsub/ab.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + await delay(); + await write(getFixturePath('change.txt'), dateNow()); + await write(addPath, dateNow()); + await write(ignoredPath, dateNow()); + await waitFor([spy.withArgs(EV.CHANGE, addPath), spy.withArgs(EV.CHANGE, changePath)]); + spy.should.have.been.calledWith(EV.ADD_DIR, getFixturePath('subdir/subsub')); + spy.should.have.been.calledWith(EV.CHANGE, changePath); + spy.should.have.been.calledWith(EV.CHANGE, addPath); + spy.should.not.have.been.calledWith(EV.ADD, ignoredPath); + spy.should.not.have.been.calledWith(EV.CHANGE, ignoredPath); + if (!macosFswatch) spy.callCount.should.equal(8); + }); + it('should respect depth setting when following symlinks', async () => { + if (isWindows) return true; // skip on windows + options.depth = 1; + await fs_symlink( + getFixturePath('subdir'), + getFixturePath('link'), + isWindows ? 'dir' : null + ); + await delay(); + const spy = await aspy(chokidar_watch(), EV.ALL); + spy.should.have.been.calledWith(EV.ADD_DIR, getFixturePath('link')); + spy.should.have.been.calledWith(EV.ADD_DIR, getFixturePath('link/subsub')); + spy.should.have.been.calledWith(EV.ADD, getFixturePath('link/add.txt')); + spy.should.not.have.been.calledWith(EV.ADD, getFixturePath('link/subsub/ab.txt')); + }); + it('should respect depth setting when following a new symlink', async () => { + if (isWindows) return true; // skip on windows + options.depth = 1; + options.ignoreInitial = true; + const linkPath = getFixturePath('link'); + const dirPath = getFixturePath('link/subsub'); + const spy = await aspy(chokidar_watch(), EV.ALL); + await fs_symlink(getFixturePath('subdir'), linkPath, isWindows ? 'dir' : null); + await waitFor([[spy, 3], spy.withArgs(EV.ADD_DIR, dirPath)]); + spy.should.have.been.calledWith(EV.ADD_DIR, linkPath); + spy.should.have.been.calledWith(EV.ADD_DIR, dirPath); + spy.should.have.been.calledWith(EV.ADD, getFixturePath('link/add.txt')); + spy.should.have.been.calledThrice; + }); + it('should correctly handle dir events when depth is 0', async () => { + options.depth = 0; + const subdir2 = getFixturePath('subdir2'); + const spy = await aspy(chokidar_watch(), EV.ALL); + const addSpy = spy.withArgs(EV.ADD_DIR); + const unlinkSpy = spy.withArgs(EV.UNLINK_DIR); + spy.should.have.been.calledWith(EV.ADD_DIR, currentDir); + spy.should.have.been.calledWith(EV.ADD_DIR, getFixturePath('subdir')); + await fs_mkdir(subdir2, PERM_ARR); + await waitFor([[addSpy, 3]]); + addSpy.should.have.been.calledThrice; + + await fs_rmdir(subdir2); + await waitFor([unlinkSpy]); + await delay(); + unlinkSpy.should.have.been.calledWith(EV.UNLINK_DIR, subdir2); + unlinkSpy.should.have.been.calledOnce; + }); + }); + describe('atomic', () => { + beforeEach(() => { + options.atomic = true; + options.ignoreInitial = true; + }); + it('should ignore vim/emacs/Sublime swapfiles', async () => { + const spy = await aspy(chokidar_watch(), EV.ALL); + await write(getFixturePath('.change.txt.swp'), 'a'); // vim + await write(getFixturePath('add.txt~'), 'a'); // vim/emacs + await write(getFixturePath('.subl5f4.tmp'), 'a'); // sublime + await delay(300); + await write(getFixturePath('.change.txt.swp'), 'c'); + await write(getFixturePath('add.txt~'), 'c'); + await write(getFixturePath('.subl5f4.tmp'), 'c'); + await delay(300); + await fs_unlink(getFixturePath('.change.txt.swp')); + await fs_unlink(getFixturePath('add.txt~')); + await fs_unlink(getFixturePath('.subl5f4.tmp')); + await delay(300); + spy.should.not.have.been.called; + }); + it('should ignore stale tilde files', async () => { + options.ignoreInitial = false; + await write(getFixturePath('old.txt~'), 'a'); + await delay(); + const spy = await aspy(chokidar_watch(), EV.ALL); + spy.should.not.have.been.calledWith(getFixturePath('old.txt')); + spy.should.not.have.been.calledWith(getFixturePath('old.txt~')); + }); + }); + describe('cwd', () => { + it('should emit relative paths based on cwd', async () => { + options.cwd = currentDir; + const watcher = chokidar_watch('.', options); + const spy = await aspy(watcher, EV.ALL); + await fs_unlink(getFixturePath('unlink.txt')); + await write(getFixturePath('change.txt'), dateNow()); + await waitFor([spy.withArgs(EV.UNLINK)]); + spy.should.have.been.calledWith(EV.ADD, 'change.txt'); + spy.should.have.been.calledWith(EV.ADD, 'unlink.txt'); + spy.should.have.been.calledWith(EV.CHANGE, 'change.txt'); + spy.should.have.been.calledWith(EV.UNLINK, 'unlink.txt'); + }); + it('should emit `addDir` with alwaysStat for renamed directory', async () => { + options.cwd = currentDir; + options.alwaysStat = true; + options.ignoreInitial = true; + const spy = sinon.spy(); + const testDir = getFixturePath('subdir'); + const renamedDir = getFixturePath('subdir-renamed'); + + await fs_mkdir(testDir, PERM_ARR); + const watcher = chokidar_watch('.', options); + + setTimeout(() => { + watcher.on(EV.ADD_DIR, spy); + fs_rename(testDir, renamedDir); + }, 1000); + + await waitFor([spy]); + spy.should.have.been.calledOnce; + spy.should.have.been.calledWith('subdir-renamed'); + expect(spy.args[0][1]).to.be.ok; // stats + }); + it('should allow separate watchers to have different cwds', async () => { + options.cwd = currentDir; + const options2 = {}; + Object.keys(options).forEach((key) => { + options2[key] = options[key]; + }); + options2.cwd = getFixturePath('subdir'); + const watcher = chokidar_watch(getGlobPath('.'), options); + const watcherEvents = waitForEvents(watcher, 3); + const spy1 = await aspy(watcher, EV.ALL); + + await delay(); + const watcher2 = chokidar_watch(currentDir, options2); + const watcher2Events = waitForEvents(watcher2, 5); + const spy2 = await aspy(watcher2, EV.ALL); + + await fs_unlink(getFixturePath('unlink.txt')); + await write(getFixturePath('change.txt'), dateNow()); + await Promise.all([watcherEvents, watcher2Events]); + spy1.should.have.been.calledWith(EV.CHANGE, 'change.txt'); + spy1.should.have.been.calledWith(EV.UNLINK, 'unlink.txt'); + spy2.should.have.been.calledWith(EV.ADD, sysPath.join('..', 'change.txt')); + spy2.should.have.been.calledWith(EV.ADD, sysPath.join('..', 'unlink.txt')); + spy2.should.have.been.calledWith(EV.CHANGE, sysPath.join('..', 'change.txt')); + spy2.should.have.been.calledWith(EV.UNLINK, sysPath.join('..', 'unlink.txt')); + }); + it('should ignore files even with cwd', async () => { + options.cwd = currentDir; + options.ignored = ['ignored-option.txt', 'ignored.txt']; + const files = ['.']; + fs.writeFileSync(getFixturePath('change.txt'), 'hello'); + fs.writeFileSync(getFixturePath('ignored.txt'), 'ignored'); + fs.writeFileSync(getFixturePath('ignored-option.txt'), 'ignored option'); + const watcher = chokidar_watch(files, options); + + const spy = await aspy(watcher, EV.ALL); + fs.writeFileSync(getFixturePath('ignored.txt'), dateNow()); + fs.writeFileSync(getFixturePath('ignored-option.txt'), dateNow()); + await fs_unlink(getFixturePath('ignored.txt')); + await fs_unlink(getFixturePath('ignored-option.txt')); + await delay(); + await write(getFixturePath('change.txt'), EV.CHANGE); + await waitFor([spy.withArgs(EV.CHANGE, 'change.txt')]); + spy.should.have.been.calledWith(EV.ADD, 'change.txt'); + spy.should.not.have.been.calledWith(EV.ADD, 'ignored.txt'); + spy.should.not.have.been.calledWith(EV.ADD, 'ignored-option.txt'); + spy.should.not.have.been.calledWith(EV.CHANGE, 'ignored.txt'); + spy.should.not.have.been.calledWith(EV.CHANGE, 'ignored-option.txt'); + spy.should.not.have.been.calledWith(EV.UNLINK, 'ignored.txt'); + spy.should.not.have.been.calledWith(EV.UNLINK, 'ignored-option.txt'); + spy.should.have.been.calledWith(EV.CHANGE, 'change.txt'); + }); + }); + describe('ignorePermissionErrors', () => { + let filePath; + beforeEach(async () => { + filePath = getFixturePath('add.txt'); + await write(filePath, 'b', { mode: 128 }); + await delay(); + }); + describe('false', () => { + beforeEach(() => { + options.ignorePermissionErrors = false; + // chokidar_watch(); + }); + it('should not watch files without read permissions', async () => { + if (isWindows) return true; + const spy = await aspy(chokidar_watch(), EV.ALL); + spy.should.not.have.been.calledWith(EV.ADD, filePath); + await write(filePath, dateNow()); + + await delay(200); + spy.should.not.have.been.calledWith(EV.CHANGE, filePath); + }); + }); + describe('true', () => { + beforeEach(() => { + options.ignorePermissionErrors = true; + }); + it('should watch unreadable files if possible', async () => { + const spy = await aspy(chokidar_watch(), EV.ALL); + spy.should.have.been.calledWith(EV.ADD, filePath); + }); + it('should not choke on non-existent files', async () => { + const watcher = chokidar_watch(getFixturePath('nope.txt'), options); + await waitForWatcher(watcher); + }); + }); + }); + describe('awaitWriteFinish', () => { + beforeEach(() => { + options.awaitWriteFinish = { stabilityThreshold: 500 }; + options.ignoreInitial = true; + }); + it('should use default options if none given', () => { + options.awaitWriteFinish = true; + const watcher = chokidar_watch(); + expect(watcher.options.awaitWriteFinish.pollInterval).to.equal(100); + expect(watcher.options.awaitWriteFinish.stabilityThreshold).to.equal(2000); + }); + it('should not emit add event before a file is fully written', async () => { + const testPath = getFixturePath('add.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + await write(testPath, 'hello'); + await delay(200); + spy.should.not.have.been.calledWith(EV.ADD); + }); + it('should wait for the file to be fully written before emitting the add event', async () => { + const testPath = getFixturePath('add.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + await write(testPath, 'hello'); + + await delay(300); + spy.should.not.have.been.called; + await waitFor([spy]); + spy.should.have.been.calledWith(EV.ADD, testPath); + }); + it('should emit with the final stats', async () => { + const testPath = getFixturePath('add.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + await write(testPath, 'hello '); + + await delay(300); + fs.appendFileSync(testPath, 'world!'); + + await waitFor([spy]); + spy.should.have.been.calledWith(EV.ADD, testPath); + expect(spy.args[0][2].size).to.equal(12); + }); + it('should not emit change event while a file has not been fully written', async () => { + const testPath = getFixturePath('add.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + await write(testPath, 'hello'); + await delay(100); + await write(testPath, 'edit'); + await delay(200); + spy.should.not.have.been.calledWith(EV.CHANGE, testPath); + }); + it('should not emit change event before an existing file is fully updated', async () => { + const testPath = getFixturePath('change.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + await write(testPath, 'hello'); + await delay(300); + spy.should.not.have.been.calledWith(EV.CHANGE, testPath); + }); + it('should wait for an existing file to be fully updated before emitting the change event', async () => { + const testPath = getFixturePath('change.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + fs.writeFile(testPath, 'hello', () => {}); + + await delay(300); + spy.should.not.have.been.called; + await waitFor([spy]); + spy.should.have.been.calledWith(EV.CHANGE, testPath); + }); + it('should emit change event after the file is fully written', async () => { + const testPath = getFixturePath('add.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + await delay(); + await write(testPath, 'hello'); + + await waitFor([spy]); + spy.should.have.been.calledWith(EV.ADD, testPath); + await write(testPath, 'edit'); + await waitFor([spy.withArgs(EV.CHANGE)]); + spy.should.have.been.calledWith(EV.CHANGE, testPath); + }); + it('should not raise any event for a file that was deleted before fully written', async () => { + const testPath = getFixturePath('add.txt'); + const spy = await aspy(chokidar_watch(), EV.ALL); + await write(testPath, 'hello'); + await delay(400); + await fs_unlink(testPath); + await delay(400); + spy.should.not.have.been.calledWith(sinon.match.string, testPath); + }); + it('should be compatible with the cwd option', async () => { + const testPath = getFixturePath('subdir/add.txt'); + const filename = sysPath.basename(testPath); + options.cwd = sysPath.dirname(testPath); + await fs_mkdir(options.cwd); + + await delay(200); + const spy = await aspy(chokidar_watch(), EV.ALL); + + await delay(400); + await write(testPath, 'hello'); + + await waitFor([spy.withArgs(EV.ADD)]); + spy.should.have.been.calledWith(EV.ADD, filename); + }); + it('should still emit initial add events', async () => { + options.ignoreInitial = false; + const spy = await aspy(chokidar_watch(), EV.ALL); + spy.should.have.been.calledWith(EV.ADD); + spy.should.have.been.calledWith(EV.ADD_DIR); + }); + it('should emit an unlink event when a file is updated and deleted just after that', async () => { + const testPath = getFixturePath('subdir/add.txt'); + const filename = sysPath.basename(testPath); + options.cwd = sysPath.dirname(testPath); + await fs_mkdir(options.cwd); + await delay(); + await write(testPath, 'hello'); + await delay(); + const spy = await aspy(chokidar_watch(), EV.ALL); + await write(testPath, 'edit'); + await delay(); + await fs_unlink(testPath); + await waitFor([spy.withArgs(EV.UNLINK)]); + spy.should.have.been.calledWith(EV.UNLINK, filename); + spy.should.not.have.been.calledWith(EV.CHANGE, filename); + }); + describe('race condition', () => { + function w(fn, to) { + return setTimeout.bind(null, fn, to || slowerDelay || 50); + } + function simpleCb(err) { + if (err) throw err; + } + + // Reproduces bug https://github.com/paulmillr/chokidar/issues/546, which was causing an + // uncaught exception. The race condition is likelier to happen when stat() is slow. + const _realStat = fs.stat; + beforeEach(() => { + options.awaitWriteFinish = { pollInterval: 50, stabilityThreshold: 50 }; + options.ignoreInitial = true; + + // Stub fs.stat() to take a while to return. + sinon.stub(fs, 'stat').callsFake((path, cb) => { + _realStat(path, w(cb, 250)); + }); + }); + + afterEach(() => { + // Restore fs.stat() back to normal. + sinon.restore(); + }); + + function _waitFor(spies, fn) { + function isSpyReady(spy) { + return Array.isArray(spy) ? spy[0].callCount >= spy[1] : spy.callCount; + } + // eslint-disable-next-line prefer-const + let intrvl; + // eslint-disable-next-line prefer-const + let to; + function finish() { + clearInterval(intrvl); + clearTimeout(to); + fn(); + fn = Function.prototype; + } + intrvl = setInterval(() => { + if (spies.every(isSpyReady)) finish(); + }, 5); + to = setTimeout(finish, 3500); + } + + it('should handle unlink that happens while waiting for stat to return', (done) => { + const spy = sinon.spy(); + const testPath = getFixturePath('add.txt'); + chokidar_watch() + .on(EV.ALL, spy) + .on(EV.READY, () => { + fs.writeFile(testPath, 'hello', simpleCb); + _waitFor([spy], () => { + spy.should.have.been.calledWith(EV.ADD, testPath); + fs.stat.resetHistory(); + fs.writeFile(testPath, 'edit', simpleCb); + w(() => { + // There will be a stat() call after we notice the change, plus pollInterval. + // After waiting a bit less, wait specifically for that stat() call. + fs.stat.resetHistory(); + _waitFor([fs.stat], () => { + // Once stat call is made, it will take some time to return. Meanwhile, unlink + // the file and wait for that to be noticed. + fs.unlink(testPath, simpleCb); + _waitFor( + [spy.withArgs(EV.UNLINK)], + w(() => { + // Wait a while after unlink to ensure stat() had time to return. That's where + // an uncaught exception used to happen. + spy.should.have.been.calledWith(EV.UNLINK, testPath); + spy.should.not.have.been.calledWith(EV.CHANGE); + done(); + }, 400) + ); + }); + }, 40)(); + }); + }); + }); + }); + }); + }); + describe('getWatched', () => { + it('should return the watched paths', async () => { + const expected = {}; + expected[sysPath.dirname(currentDir)] = [subdirId.toString()]; + expected[currentDir] = ['change.txt', 'unlink.txt']; + const watcher = chokidar_watch(); + await waitForWatcher(watcher); + expect(watcher.getWatched()).to.deep.equal(expected); + }); + it('should set keys relative to cwd & include added paths', async () => { + options.cwd = currentDir; + const expected = { + '.': ['change.txt', 'subdir', 'unlink.txt'], + '..': [subdirId.toString()], + subdir: [], + }; + await fs_mkdir(getFixturePath('subdir'), PERM_ARR); + const watcher = chokidar_watch(); + await waitForWatcher(watcher); + expect(watcher.getWatched()).to.deep.equal(expected); + }); + }); + describe('unwatch', () => { + beforeEach(async () => { + options.ignoreInitial = true; + await fs_mkdir(getFixturePath('subdir'), PERM_ARR); + await delay(); + }); + it('should stop watching unwatched paths', async () => { + const watchPaths = [getFixturePath('subdir'), getFixturePath('change.txt')]; + const watcher = chokidar_watch(watchPaths, options); + const spy = await aspy(watcher, EV.ALL); + watcher.unwatch(getFixturePath('subdir')); + + await delay(); + await write(getFixturePath('subdir/add.txt'), dateNow()); + await write(getFixturePath('change.txt'), dateNow()); + await waitFor([spy]); + + await delay(300); + spy.should.have.been.calledWith(EV.CHANGE, getFixturePath('change.txt')); + spy.should.not.have.been.calledWith(EV.ADD); + if (!macosFswatch) spy.should.have.been.calledOnce; + }); + it('should ignore unwatched paths that are a subset of watched paths', async () => { + const subdirRel = upath.relative(process.cwd(), getFixturePath('subdir')); + const unlinkFile = getFixturePath('unlink.txt'); + const addFile = getFixturePath('subdir/add.txt'); + const changedFile = getFixturePath('change.txt'); + const watcher = chokidar_watch(currentDir, options); + const spy = await aspy(watcher, EV.ALL); + + // test with both relative and absolute paths + watcher.unwatch([subdirRel, getGlobPath('unlink.txt')]); + + await delay(); + await fs_unlink(unlinkFile); + await write(addFile, dateNow()); + await write(changedFile, dateNow()); + await waitFor([spy.withArgs(EV.CHANGE)]); + + await delay(300); + spy.should.have.been.calledWith(EV.CHANGE, changedFile); + spy.should.not.have.been.calledWith(EV.ADD, addFile); + spy.should.not.have.been.calledWith(EV.UNLINK, unlinkFile); + if (!macosFswatch) spy.should.have.been.calledOnce; + }); + it('should unwatch relative paths', async () => { + const fixturesDir = sysPath.relative(process.cwd(), currentDir); + const subdir = sysPath.join(fixturesDir, 'subdir'); + const changeFile = sysPath.join(fixturesDir, 'change.txt'); + const watchPaths = [subdir, changeFile]; + const watcher = chokidar_watch(watchPaths, options); + const spy = await aspy(watcher, EV.ALL); + + await delay(); + watcher.unwatch(subdir); + await write(getFixturePath('subdir/add.txt'), dateNow()); + await write(getFixturePath('change.txt'), dateNow()); + await waitFor([spy]); + + await delay(300); + spy.should.have.been.calledWith(EV.CHANGE, changeFile); + spy.should.not.have.been.calledWith(EV.ADD); + if (!macosFswatch) spy.should.have.been.calledOnce; + }); + it('should watch paths that were unwatched and added again', async () => { + const spy = sinon.spy(); + const watchPaths = [getFixturePath('change.txt')]; + const watcher = chokidar_watch(watchPaths, options); + await waitForWatcher(watcher); + + await delay(); + watcher.unwatch(getFixturePath('change.txt')); + + await delay(); + watcher.on(EV.ALL, spy).add(getFixturePath('change.txt')); + + await delay(); + await write(getFixturePath('change.txt'), dateNow()); + await waitFor([spy]); + spy.should.have.been.calledWith(EV.CHANGE, getFixturePath('change.txt')); + if (!macosFswatch) spy.should.have.been.calledOnce; + }); + it('should unwatch paths that are relative to options.cwd', async () => { + options.cwd = currentDir; + const watcher = chokidar_watch('.', options); + const spy = await aspy(watcher, EV.ALL); + watcher.unwatch(['subdir', getFixturePath('unlink.txt')]); + + await delay(); + await fs_unlink(getFixturePath('unlink.txt')); + await write(getFixturePath('subdir/add.txt'), dateNow()); + await write(getFixturePath('change.txt'), dateNow()); + await waitFor([spy]); + + await delay(300); + spy.should.have.been.calledWith(EV.CHANGE, 'change.txt'); + spy.should.not.have.been.calledWith(EV.ADD); + spy.should.not.have.been.calledWith(EV.UNLINK); + if (!macosFswatch) spy.should.have.been.calledOnce; + }); + }); + describe('env variable option override', () => { + describe('CHOKIDAR_USEPOLLING', () => { + afterEach(() => { + delete process.env.CHOKIDAR_USEPOLLING; + }); + + it('should make options.usePolling `true` when CHOKIDAR_USEPOLLING is set to true', async () => { + options.usePolling = false; + process.env.CHOKIDAR_USEPOLLING = 'true'; + const watcher = chokidar_watch(currentDir, options); + await waitForWatcher(watcher); + watcher.options.usePolling.should.be.true; + }); + + it('should make options.usePolling `true` when CHOKIDAR_USEPOLLING is set to 1', async () => { + options.usePolling = false; + process.env.CHOKIDAR_USEPOLLING = '1'; + + const watcher = chokidar_watch(currentDir, options); + await waitForWatcher(watcher); + watcher.options.usePolling.should.be.true; + }); + + it('should make options.usePolling `false` when CHOKIDAR_USEPOLLING is set to false', async () => { + options.usePolling = true; + process.env.CHOKIDAR_USEPOLLING = 'false'; + + const watcher = chokidar_watch(currentDir, options); + await waitForWatcher(watcher); + watcher.options.usePolling.should.be.false; + }); + + it('should make options.usePolling `false` when CHOKIDAR_USEPOLLING is set to 0', async () => { + options.usePolling = true; + process.env.CHOKIDAR_USEPOLLING = 'false'; + + const watcher = chokidar_watch(currentDir, options); + await waitForWatcher(watcher); + watcher.options.usePolling.should.be.false; + }); + + it('should not attenuate options.usePolling when CHOKIDAR_USEPOLLING is set to an arbitrary value', async () => { + options.usePolling = true; + process.env.CHOKIDAR_USEPOLLING = 'foo'; + + const watcher = chokidar_watch(currentDir, options); + await waitForWatcher(watcher); + watcher.options.usePolling.should.be.true; + }); + }); + if (options && options.usePolling) { + describe('CHOKIDAR_INTERVAL', () => { + afterEach(() => { + delete process.env.CHOKIDAR_INTERVAL; + }); + it('should make options.interval = CHOKIDAR_INTERVAL when it is set', async () => { + options.interval = 100; + process.env.CHOKIDAR_INTERVAL = '1500'; + + const watcher = chokidar_watch(currentDir, options); + await waitForWatcher(watcher); + watcher.options.interval.should.be.equal(1500); + }); + }); + } + }); + describe('reproduction of bug in issue #1040', () => { + it('should detect change on symlink folders when consolidateThreshhold is reach', async () => { + const id = subdirId.toString(); + + const fixturesPathRel = sysPath.join(FIXTURES_PATH_REL, id, 'test-case-1040'); + const linkPath = sysPath.join(fixturesPathRel, 'symlinkFolder'); + const packagesPath = sysPath.join(fixturesPathRel, 'packages'); + await fs_mkdir(fixturesPathRel); + await fs_mkdir(linkPath); + await fs_mkdir(packagesPath); + + // Init chokidar + const watcher = chokidar.watch([]); + + // Add more than 10 folders to cap consolidateThreshhold + for (let i = 0; i < 20; i += 1) { + const folderPath = sysPath.join(packagesPath, `folder${i}`); + await fs_mkdir(folderPath); + const filePath = sysPath.join(folderPath, `file${i}.js`); + await write(sysPath.resolve(filePath), 'file content'); + const symlinkPath = sysPath.join(linkPath, `folder${i}`); + await fs_symlink(sysPath.resolve(folderPath), symlinkPath, isWindows ? 'dir' : null); + watcher.add(sysPath.resolve(sysPath.join(symlinkPath, `file${i}.js`))); + } + + // Wait to be sure that we have no other event than the update file + await delay(300); + + const eventsWaiter = waitForEvents(watcher, 1); + + // Update a random generated file to fire an event + const randomFilePath = sysPath.join(fixturesPathRel, 'packages', 'folder17', 'file17.js'); + await write(sysPath.resolve(randomFilePath), 'file content changer zeri ezhriez'); + + // Wait chokidar watch + await delay(300); + + const events = await eventsWaiter; + + expect(events.length).to.equal(1); + }); + }); + describe('reproduction of bug in issue #1024', () => { + it('should detect changes to folders, even if they were deleted before', async () => { + const id = subdirId.toString(); + const relativeWatcherDir = sysPath.join(FIXTURES_PATH_REL, id, 'test'); + const watcher = chokidar.watch(relativeWatcherDir, { + persistent: true, + }); + try { + const eventsWaiter = waitForEvents(watcher, 5); + const testSubDir = sysPath.join(relativeWatcherDir, 'dir'); + const testSubDirFile = sysPath.join(relativeWatcherDir, 'dir', 'file'); + + // Command sequence from https://github.com/paulmillr/chokidar/issues/1042. + await delay(); + await fs_mkdir(relativeWatcherDir); + await fs_mkdir(testSubDir); + // The following delay is essential otherwise the call of mkdir and rmdir will be equalize + await delay(300); + await fs_rmdir(testSubDir); + // The following delay is essential otherwise the call of rmdir and mkdir will be equalize + await delay(300); + await fs_mkdir(testSubDir); + await delay(300); + await write(testSubDirFile, ''); + await delay(300); + + const events = await eventsWaiter; + + chai.assert.deepStrictEqual(events, [ + `[ALL] addDir: ${sysPath.join('test-fixtures', id, 'test')}`, + `[ALL] addDir: ${sysPath.join('test-fixtures', id, 'test', 'dir')}`, + `[ALL] unlinkDir: ${sysPath.join('test-fixtures', id, 'test', 'dir')}`, + `[ALL] addDir: ${sysPath.join('test-fixtures', id, 'test', 'dir')}`, + `[ALL] add: ${sysPath.join('test-fixtures', id, 'test', 'dir', 'file')}`, + ]); + } finally { + watcher.close(); + } + }); + + it('should detect changes to symlink folders, even if they were deleted before', async () => { + const id = subdirId.toString(); + const relativeWatcherDir = sysPath.join(FIXTURES_PATH_REL, id, 'test'); + const linkedRelativeWatcherDir = sysPath.join(FIXTURES_PATH_REL, id, 'test-link'); + await fs_symlink( + sysPath.resolve(relativeWatcherDir), + linkedRelativeWatcherDir, + isWindows ? 'dir' : null + ); + await delay(); + const watcher = chokidar.watch(linkedRelativeWatcherDir, { + persistent: true, + }); + try { + const eventsWaiter = waitForEvents(watcher, 5); + const testSubDir = sysPath.join(relativeWatcherDir, 'dir'); + const testSubDirFile = sysPath.join(relativeWatcherDir, 'dir', 'file'); + + // Command sequence from https://github.com/paulmillr/chokidar/issues/1042. + await delay(); + await fs_mkdir(relativeWatcherDir); + await fs_mkdir(testSubDir); + // The following delay is essential otherwise the call of mkdir and rmdir will be equalize + await delay(300); + await fs_rmdir(testSubDir); + // The following delay is essential otherwise the call of rmdir and mkdir will be equalize + await delay(300); + await fs_mkdir(testSubDir); + await delay(300); + await write(testSubDirFile, ''); + await delay(300); + + const events = await eventsWaiter; + + chai.assert.deepStrictEqual(events, [ + `[ALL] addDir: ${sysPath.join('test-fixtures', id, 'test-link')}`, + `[ALL] addDir: ${sysPath.join('test-fixtures', id, 'test-link', 'dir')}`, + `[ALL] unlinkDir: ${sysPath.join('test-fixtures', id, 'test-link', 'dir')}`, + `[ALL] addDir: ${sysPath.join('test-fixtures', id, 'test-link', 'dir')}`, + `[ALL] add: ${sysPath.join('test-fixtures', id, 'test-link', 'dir', 'file')}`, + ]); + } finally { + watcher.close(); + } + }); + }); + + describe('close', () => { + it('should ignore further events on close', async () => { + const spy = sinon.spy(); + const watcher = chokidar_watch(currentDir, options); + await waitForWatcher(watcher); + + watcher.on(EV.ALL, spy); + await watcher.close(); + + await write(getFixturePath('add.txt'), dateNow()); + await write(getFixturePath('add.txt'), 'hello'); + await delay(300); + await fs_unlink(getFixturePath('add.txt')); + + spy.should.not.have.been.called; + }); + it('should not ignore further events on close with existing watchers', async () => { + const spy = sinon.spy(); + const watcher1 = chokidar_watch(currentDir); + const watcher2 = chokidar_watch(currentDir); + await Promise.all([waitForWatcher(watcher1), waitForWatcher(watcher2)]); + + // The EV_ADD event should be called on the second watcher even if the first watcher is closed + watcher2.on(EV.ADD, spy); + await watcher1.close(); + + await write(getFixturePath('add.txt'), 'hello'); + // Ensures EV_ADD is called. Immediately removing the file causes it to be skipped + await delay(200); + await fs_unlink(getFixturePath('add.txt')); + + spy.should.have.been.calledWith(sinon.match('add.txt')); + }); + it('should not prevent the process from exiting', async () => { + const scriptFile = getFixturePath('script.js'); + const chokidarPath = pathToFileURL(sysPath.join(__dirname, 'lib/v4.js')).href.replace( + /\\/g, + '\\\\' + ); + const scriptContent = ` + (async () => { + const chokidar = await import("${chokidarPath}"); + const watcher = chokidar.watch("${scriptFile.replace(/\\/g, '\\\\')}"); + watcher.on("ready", () => { + watcher.close(); + process.stdout.write("closed"); + }); + })();`; + await write(scriptFile, scriptContent); + const obj = await exec(`node ${scriptFile}`); + const { stdout } = obj; + expect(stdout.toString()).to.equal('closed'); + }); + it('should always return the same promise', async () => { + const watcher = chokidar_watch(currentDir, options); + const closePromise = watcher.close(); + expect(closePromise).to.be.a('promise'); + expect(watcher.close()).to.be.equal(closePromise); + await closePromise; + }); + }); +}; + +describe('chokidar', async () => { + before(async () => { + await rimraf(FIXTURES_PATH); + const _content = fs.readFileSync(__filename, 'utf-8'); + const _only = _content.match(/\sit\.only\(/g); + const itCount = (_only && _only.length) || _content.match(/\sit\(/g).length; + const testCount = itCount * 3; + fs.mkdirSync(currentDir, PERM_ARR); + while (subdirId++ < testCount) { + currentDir = getFixturePath(''); + fs.mkdirSync(currentDir, PERM_ARR); + fs.writeFileSync(sysPath.join(currentDir, 'change.txt'), 'b'); + fs.writeFileSync(sysPath.join(currentDir, 'unlink.txt'), 'b'); + } + subdirId = 0; + }); + + after(async () => { + await rimraf(FIXTURES_PATH); + }); + + beforeEach(() => { + subdirId++; + currentDir = getFixturePath(''); + }); + + afterEach(async () => { + let watcher; + while ((watcher = allWatchers.pop())) { + await watcher.close(); + } + }); + + it('should expose public API methods', () => { + chokidar.FSWatcher.should.be.a('function'); + chokidar.watch.should.be.a('function'); + }); + + if (!isIBMi) { + describe('fs.watch (non-polling)', runTests.bind(this, { usePolling: false })); + } + describe('fs.watchFile (polling)', runTests.bind(this, { usePolling: true, interval: 10 })); +}); diff --git a/tsconfig.json b/tsconfig.json index 1bebc3f6..42041e71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "esModuleInterop": true, "baseUrl": ".", }, - "include": ["src", "types"], + "include": ["src/v4.ts"], "exclude": ["node_modules", "lib"] }