diff --git a/doc/api/fs.md b/doc/api/fs.md index 6e86072ca6031e..6ea9fa9fdde0f2 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -1837,6 +1837,10 @@ added: * `overflow` {string} Either `'ignore'` or `'throw'` when there are more events to be queued than `maxQueue` allows. `'ignore'` means overflow events are dropped and a warning is emitted, while `'throw'` means to throw an exception. **Default:** `'ignore'`. + * `ignore` {string|RegExp|Function|Array} Pattern(s) to ignore. Strings are + glob patterns (using [`minimatch`][]), RegExp patterns are tested against + the filename, and functions receive the filename and return `true` to + ignore. **Default:** `undefined`. * Returns: {AsyncIterator} of objects with the properties: * `eventType` {string} The type of change * `filename` {string|Buffer|null} The name of the file changed. @@ -4804,6 +4808,10 @@ changes: * `encoding` {string} Specifies the character encoding to be used for the filename passed to the listener. **Default:** `'utf8'`. * `signal` {AbortSignal} allows closing the watcher with an AbortSignal. + * `ignore` {string|RegExp|Function|Array} Pattern(s) to ignore. Strings are + glob patterns (using [`minimatch`][]), RegExp patterns are tested against + the filename, and functions receive the filename and return `true` to + ignore. **Default:** `undefined`. * `listener` {Function|undefined} **Default:** `undefined` * `eventType` {string} * `filename` {string|Buffer|null} @@ -8764,6 +8772,7 @@ the file contents. [`fsPromises.utimes()`]: #fspromisesutimespath-atime-mtime [`inotify(7)`]: https://man7.org/linux/man-pages/man7/inotify.7.html [`kqueue(2)`]: https://www.freebsd.org/cgi/man.cgi?query=kqueue&sektion=2 +[`minimatch`]: https://github.com/isaacs/minimatch [`util.promisify()`]: util.md#utilpromisifyoriginal [bigints]: https://tc39.github.io/proposal-bigint [caveats]: #caveats diff --git a/lib/fs.js b/lib/fs.js index 1c65c01c939f39..4c34bf3c004639 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -2521,7 +2521,8 @@ function watch(filename, options, listener) { watcher[watchers.kFSWatchStart](path, options.persistent, options.recursive, - options.encoding); + options.encoding, + options.ignore); } if (listener) { diff --git a/lib/internal/fs/recursive_watch.js b/lib/internal/fs/recursive_watch.js index 29d8c23fdfbe31..0ee75323ef40e9 100644 --- a/lib/internal/fs/recursive_watch.js +++ b/lib/internal/fs/recursive_watch.js @@ -17,9 +17,9 @@ const { }, } = require('internal/errors'); const { getValidatedPath } = require('internal/fs/utils'); -const { kFSWatchStart, StatWatcher } = require('internal/fs/watchers'); +const { createIgnoreMatcher, kFSWatchStart, StatWatcher } = require('internal/fs/watchers'); const { kEmptyObject } = require('internal/util'); -const { validateBoolean, validateAbortSignal } = require('internal/validators'); +const { validateBoolean, validateAbortSignal, validateIgnoreOption } = require('internal/validators'); const { basename: pathBasename, join: pathJoin, @@ -44,13 +44,14 @@ class FSWatcher extends EventEmitter { #symbolicFiles = new SafeSet(); #rootPath = pathResolve(); #watchingFile = false; + #ignoreMatcher = null; constructor(options = kEmptyObject) { super(); assert(typeof options === 'object'); - const { persistent, recursive, signal, encoding } = options; + const { persistent, recursive, signal, encoding, ignore } = options; // TODO(anonrig): Add non-recursive support to non-native-watcher for IBMi & AIX support. if (recursive != null) { @@ -72,6 +73,9 @@ class FSWatcher extends EventEmitter { } } + validateIgnoreOption(ignore, 'options.ignore'); + this.#ignoreMatcher = createIgnoreMatcher(ignore); + this.#options = { persistent, recursive, signal, encoding }; } @@ -92,6 +96,14 @@ class FSWatcher extends EventEmitter { this.emit('close'); } + #emitChange(eventType, filename) { + // Filter events if ignore matcher is set and filename is available + if (filename != null && this.#ignoreMatcher?.(filename)) { + return; + } + this.emit('change', eventType, filename); + } + #unwatchFiles(file) { this.#symbolicFiles.delete(file); @@ -120,7 +132,7 @@ class FSWatcher extends EventEmitter { const f = pathJoin(folder, file.name); if (!this.#files.has(f)) { - this.emit('change', 'rename', pathRelative(this.#rootPath, f)); + this.#emitChange('rename', pathRelative(this.#rootPath, f)); if (file.isSymbolicLink()) { this.#symbolicFiles.add(f); @@ -178,20 +190,20 @@ class FSWatcher extends EventEmitter { this.#files.delete(file); this.#watchers.delete(file); watcher.close(); - this.emit('change', 'rename', pathRelative(this.#rootPath, file)); + this.#emitChange('rename', pathRelative(this.#rootPath, file)); this.#unwatchFiles(file); } else if (file === this.#rootPath && this.#watchingFile) { // This case will only be triggered when watching a file with fs.watch - this.emit('change', 'change', pathBasename(file)); + this.#emitChange('change', pathBasename(file)); } else if (this.#symbolicFiles.has(file)) { // Stats from watchFile does not return correct value for currentStats.isSymbolicLink() // Since it is only valid when using fs.lstat(). Therefore, check the existing symbolic files. - this.emit('change', 'rename', pathRelative(this.#rootPath, file)); + this.#emitChange('rename', pathRelative(this.#rootPath, file)); } else if (currentStats.isDirectory()) { this.#watchFolder(file); } else { // Watching a directory will trigger a change event for child files) - this.emit('change', 'change', pathRelative(this.#rootPath, file)); + this.#emitChange('change', pathRelative(this.#rootPath, file)); } }); this.#watchers.set(file, watcher); diff --git a/lib/internal/fs/watchers.js b/lib/internal/fs/watchers.js index 605dee28cace56..0338af45eeae8b 100644 --- a/lib/internal/fs/watchers.js +++ b/lib/internal/fs/watchers.js @@ -1,6 +1,7 @@ 'use strict'; const { + ArrayIsArray, ArrayPrototypePush, ArrayPrototypeShift, Error, @@ -8,6 +9,7 @@ const { ObjectDefineProperty, ObjectSetPrototypeOf, PromiseWithResolvers, + RegExpPrototypeExec, Symbol, } = primordials; @@ -22,6 +24,8 @@ const { const { kEmptyObject, + isWindows, + isMacOS, } = require('internal/util'); const { @@ -48,6 +52,7 @@ const { toNamespacedPath } = require('path'); const { validateAbortSignal, validateBoolean, + validateIgnoreOption, validateObject, validateUint32, validateInteger, @@ -60,6 +65,8 @@ const { }, } = require('buffer'); +const { isRegExp } = require('internal/util/types'); + const assert = require('internal/assert'); const kOldStatus = Symbol('kOldStatus'); @@ -71,6 +78,54 @@ const KFSStatWatcherRefCount = Symbol('KFSStatWatcherRefCount'); const KFSStatWatcherMaxRefCount = Symbol('KFSStatWatcherMaxRefCount'); const kFSStatWatcherAddOrCleanRef = Symbol('kFSStatWatcherAddOrCleanRef'); +let minimatch; +function lazyMinimatch() { + minimatch ??= require('internal/deps/minimatch/index'); + return minimatch; +} + +/** + * Creates an ignore matcher function from the ignore option. + * @param {string | RegExp | Function | Array} ignore - The ignore patterns + * @returns {Function | null} A function that returns true if filename should be ignored + */ +function createIgnoreMatcher(ignore) { + if (ignore == null) return null; + const matchers = ArrayIsArray(ignore) ? ignore : [ignore]; + const compiled = []; + + for (let i = 0; i < matchers.length; i++) { + const matcher = matchers[i]; + if (typeof matcher === 'string') { + const mm = new (lazyMinimatch().Minimatch)(matcher, { + __proto__: null, + nocase: isWindows || isMacOS, + windowsPathsNoEscape: true, + nonegate: true, + nocomment: true, + optimizationLevel: 2, + platform: process.platform, + // matchBase allows patterns without slashes to match the basename + // e.g., '*.log' matches 'subdir/file.log' + matchBase: true, + }); + ArrayPrototypePush(compiled, (filename) => mm.match(filename)); + } else if (isRegExp(matcher)) { + ArrayPrototypePush(compiled, (filename) => RegExpPrototypeExec(matcher, filename) !== null); + } else { + // Function + ArrayPrototypePush(compiled, matcher); + } + } + + return (filename) => { + for (let i = 0; i < compiled.length; i++) { + if (compiled[i](filename)) return true; + } + return false; + }; +} + function emitStop(self) { self.emit('stop'); } @@ -199,6 +254,7 @@ function FSWatcher() { this._handle = new FSEvent(); this._handle[owner_symbol] = this; + this._ignoreMatcher = null; this._handle.onchange = (status, eventType, filename) => { // TODO(joyeecheung): we may check self._handle.initialized here @@ -219,6 +275,10 @@ function FSWatcher() { error.filename = filename; this.emit('error', error); } else { + // Filter events if ignore matcher is set and filename is available + if (filename != null && this._ignoreMatcher?.(filename)) { + return; + } this.emit('change', eventType, filename); } }; @@ -235,7 +295,8 @@ ObjectSetPrototypeOf(FSWatcher, EventEmitter); FSWatcher.prototype[kFSWatchStart] = function(filename, persistent, recursive, - encoding) { + encoding, + ignore) { if (this._handle === null) { // closed return; } @@ -246,6 +307,10 @@ FSWatcher.prototype[kFSWatchStart] = function(filename, filename = getValidatedPath(filename, 'filename'); + // Validate and create the ignore matcher + validateIgnoreOption(ignore, 'options.ignore'); + this._ignoreMatcher = createIgnoreMatcher(ignore); + const err = this._handle.start(toNamespacedPath(filename), persistent, recursive, @@ -319,6 +384,7 @@ async function* watch(filename, options = kEmptyObject) { maxQueue = 2048, overflow = 'ignore', signal, + ignore, } = options; validateBoolean(persistent, 'options.persistent'); @@ -326,6 +392,7 @@ async function* watch(filename, options = kEmptyObject) { validateInteger(maxQueue, 'options.maxQueue'); validateOneOf(overflow, 'options.overflow', ['ignore', 'error']); validateAbortSignal(signal, 'options.signal'); + validateIgnoreOption(ignore, 'options.ignore'); if (encoding && !isEncoding(encoding)) { const reason = 'is invalid encoding'; @@ -336,6 +403,7 @@ async function* watch(filename, options = kEmptyObject) { throw new AbortError(undefined, { cause: signal.reason }); const handle = new FSEvent(); + const ignoreMatcher = createIgnoreMatcher(ignore); let { promise, resolve } = PromiseWithResolvers(); const queue = []; const oncancel = () => { @@ -361,6 +429,10 @@ async function* watch(filename, options = kEmptyObject) { resolve(); return; } + // Filter events if ignore matcher is set and filename is available + if (filename != null && ignoreMatcher?.(filename)) { + return; + } if (queue.length < maxQueue) { ArrayPrototypePush(queue, { __proto__: null, eventType, filename }); resolve(); @@ -409,6 +481,7 @@ async function* watch(filename, options = kEmptyObject) { } module.exports = { + createIgnoreMatcher, FSWatcher, StatWatcher, kFSWatchStart, diff --git a/lib/internal/validators.js b/lib/internal/validators.js index efbec5e11121f7..b9845c538bb98f 100644 --- a/lib/internal/validators.js +++ b/lib/internal/validators.js @@ -35,6 +35,7 @@ const { normalizeEncoding } = require('internal/util'); const { isAsyncFunction, isArrayBufferView, + isRegExp, } = require('internal/util/types'); const { signals } = internalBinding('constants').os; @@ -575,6 +576,38 @@ const validateLinkHeaderValue = hideStackFrames((hints) => { ); }); +/** + * Validates a single ignore option element (string, RegExp, or Function). + * @param {*} value + * @param {string} name + */ +const validateIgnoreOptionElement = hideStackFrames((value, name) => { + if (typeof value === 'string') { + if (value.length === 0) + throw new ERR_INVALID_ARG_VALUE(name, value, 'must be a non-empty string'); + return; + } + if (isRegExp(value)) return; + if (typeof value === 'function') return; + throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp', 'Function'], value); +}); + +/** + * Validates the ignore option for fs.watch. + * @param {*} value + * @param {string} name + */ +const validateIgnoreOption = hideStackFrames((value, name) => { + if (value == null) return; + if (ArrayIsArray(value)) { + for (let i = 0; i < value.length; i++) { + validateIgnoreOptionElement(value[i], `${name}[${i}]`); + } + return; + } + validateIgnoreOptionElement(value, name); +}); + // 1. Returns false for undefined and NaN // 2. Returns true for finite numbers // 3. Throws ERR_INVALID_ARG_TYPE for non-numbers @@ -628,6 +661,7 @@ module.exports = { validateDictionary, validateEncoding, validateFunction, + validateIgnoreOption, validateInt32, validateInteger, validateNumber, diff --git a/test/parallel/test-fs-watch-ignore-promise.js b/test/parallel/test-fs-watch-ignore-promise.js new file mode 100644 index 00000000000000..0f3c8798a4dbfc --- /dev/null +++ b/test/parallel/test-fs-watch-ignore-promise.js @@ -0,0 +1,216 @@ +'use strict'; + +const common = require('../common'); + +if (common.isIBMi) + common.skip('IBMi does not support `fs.watch()`'); + +if (common.isAIX) + common.skip('folder watch capability is limited in AIX.'); + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs/promises'); +const fsSync = require('fs'); + +const tmpdir = require('../common/tmpdir'); +const testDir = tmpdir.path; +tmpdir.refresh(); + +// Test 1: String glob pattern ignore with Promise API +(async function testGlobIgnore() { + const testsubdir = await fs.mkdtemp(testDir + path.sep); + const keepFile = 'keep.txt'; + const ignoreFile = 'ignore.log'; + const keepFilePath = path.join(testsubdir, keepFile); + const ignoreFilePath = path.join(testsubdir, ignoreFile); + + const watcher = fs.watch(testsubdir, { ignore: '*.log' }); + + let seenIgnored = false; + let interval; + + process.on('exit', () => { + assert.ok(interval === null); + assert.strictEqual(seenIgnored, false); + }); + + process.nextTick(common.mustCall(() => { + interval = setInterval(() => { + fsSync.writeFileSync(ignoreFilePath, 'ignored'); + fsSync.writeFileSync(keepFilePath, 'content-' + Date.now()); + }, 100); + })); + + for await (const { filename } of watcher) { + if (filename === ignoreFile) { + seenIgnored = true; + } + if (filename === keepFile) { + break; + } + } + + clearInterval(interval); + interval = null; +})().then(common.mustCall()); + +// Test 2: RegExp ignore pattern with Promise API +(async function testRegExpIgnore() { + const testsubdir = await fs.mkdtemp(testDir + path.sep); + const keepFile = 'keep.txt'; + const ignoreFile = 'ignore.tmp'; + const keepFilePath = path.join(testsubdir, keepFile); + const ignoreFilePath = path.join(testsubdir, ignoreFile); + + const watcher = fs.watch(testsubdir, { ignore: /\.tmp$/ }); + + let seenIgnored = false; + let interval; + + process.on('exit', () => { + assert.ok(interval === null); + assert.strictEqual(seenIgnored, false); + }); + + process.nextTick(common.mustCall(() => { + interval = setInterval(() => { + fsSync.writeFileSync(ignoreFilePath, 'ignored'); + fsSync.writeFileSync(keepFilePath, 'content-' + Date.now()); + }, 100); + })); + + for await (const { filename } of watcher) { + if (filename === ignoreFile) { + seenIgnored = true; + } + if (filename === keepFile) { + break; + } + } + + clearInterval(interval); + interval = null; +})().then(common.mustCall()); + +// Test 3: Function ignore pattern with Promise API +(async function testFunctionIgnore() { + const testsubdir = await fs.mkdtemp(testDir + path.sep); + const keepFile = 'visible.txt'; + const ignoreFile = '.hidden'; + const keepFilePath = path.join(testsubdir, keepFile); + const ignoreFilePath = path.join(testsubdir, ignoreFile); + + const watcher = fs.watch(testsubdir, { + ignore: (filename) => filename.startsWith('.'), + }); + + let seenIgnored = false; + let interval; + + process.on('exit', () => { + assert.ok(interval === null); + assert.strictEqual(seenIgnored, false); + }); + + process.nextTick(common.mustCall(() => { + interval = setInterval(() => { + fsSync.writeFileSync(ignoreFilePath, 'ignored'); + fsSync.writeFileSync(keepFilePath, 'content-' + Date.now()); + }, 100); + })); + + for await (const { filename } of watcher) { + if (filename === ignoreFile) { + seenIgnored = true; + } + if (filename === keepFile) { + break; + } + } + + clearInterval(interval); + interval = null; +})().then(common.mustCall()); + +// Test 4: Array of mixed patterns with Promise API +(async function testArrayIgnore() { + const testsubdir = await fs.mkdtemp(testDir + path.sep); + const keepFile = 'keep.txt'; + const ignoreLog = 'debug.log'; + const ignoreTmp = 'temp.tmp'; + const keepFilePath = path.join(testsubdir, keepFile); + const ignoreLogPath = path.join(testsubdir, ignoreLog); + const ignoreTmpPath = path.join(testsubdir, ignoreTmp); + + const watcher = fs.watch(testsubdir, { + ignore: [ + '*.log', + /\.tmp$/, + ], + }); + + let seenLog = false; + let seenTmp = false; + let interval; + + process.on('exit', () => { + assert.ok(interval === null); + assert.strictEqual(seenLog, false); + assert.strictEqual(seenTmp, false); + }); + + process.nextTick(common.mustCall(() => { + interval = setInterval(() => { + fsSync.writeFileSync(ignoreLogPath, 'ignored'); + fsSync.writeFileSync(ignoreTmpPath, 'ignored'); + fsSync.writeFileSync(keepFilePath, 'content-' + Date.now()); + }, 100); + })); + + for await (const { filename } of watcher) { + if (filename === ignoreLog) seenLog = true; + if (filename === ignoreTmp) seenTmp = true; + if (filename === keepFile) { + break; + } + } + + clearInterval(interval); + interval = null; +})().then(common.mustCall()); + +// Test 5: Invalid option validation with Promise API +// Note: async generators don't execute until iteration starts, +// so we need to use assert.rejects with iteration +(async function testInvalidIgnore() { + const testsubdir = await fs.mkdtemp(testDir + path.sep); + + await assert.rejects( + async () => { + const watcher = fs.watch(testsubdir, { ignore: 123 }); + // eslint-disable-next-line no-unused-vars + for await (const _ of watcher) { + // Will throw before yielding + } + }, + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + } + ); + + await assert.rejects( + async () => { + const watcher = fs.watch(testsubdir, { ignore: '' }); + // eslint-disable-next-line no-unused-vars + for await (const _ of watcher) { + // Will throw before yielding + } + }, + { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + } + ); +})().then(common.mustCall()); diff --git a/test/parallel/test-fs-watch-ignore-recursive.js b/test/parallel/test-fs-watch-ignore-recursive.js new file mode 100644 index 00000000000000..c3990dd13cf022 --- /dev/null +++ b/test/parallel/test-fs-watch-ignore-recursive.js @@ -0,0 +1,229 @@ +'use strict'; + +const common = require('../common'); + +if (common.isIBMi) + common.skip('IBMi does not support `fs.watch()`'); + +if (common.isAIX) + common.skip('folder watch capability is limited in AIX.'); + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); + +const tmpdir = require('../common/tmpdir'); +const testDir = tmpdir.path; +tmpdir.refresh(); + +// Test 1: String glob pattern ignore with recursive watch +{ + const rootDirectory = fs.mkdtempSync(testDir + path.sep); + const testDirectory = path.join(rootDirectory, 'test-recursive-glob'); + const subDirectory = path.join(testDirectory, 'subdir'); + fs.mkdirSync(testDirectory); + fs.mkdirSync(subDirectory); + + const testFile = path.join(subDirectory, 'file.txt'); + const ignoredFile = path.join(subDirectory, 'file.log'); + + const watcher = fs.watch(testDirectory, { + recursive: true, + ignore: '*.log', + }); + + let seenFile = false; + let seenIgnored = false; + let interval; + + watcher.on('change', common.mustCallAtLeast((event, filename) => { + // On recursive watch, filename includes relative path from watched dir + if (filename && filename.endsWith('file.txt')) { + seenFile = true; + } + if (filename && filename.endsWith('file.log')) { + seenIgnored = true; + } + if (seenFile) { + clearInterval(interval); + interval = null; + watcher.close(); + } + }, 1)); + + // Use setInterval to handle potential event delays on macOS FSEvents + process.nextTick(common.mustCall(() => { + interval = setInterval(() => { + fs.writeFileSync(ignoredFile, 'ignored'); + fs.writeFileSync(testFile, 'content-' + Date.now()); + }, 100); + })); + + process.on('exit', () => { + assert.strictEqual(interval, null); + assert.strictEqual(seenFile, true); + assert.strictEqual(seenIgnored, false); + }); +} + +// Test 2: RegExp ignore pattern with recursive watch +{ + const rootDirectory = fs.mkdtempSync(testDir + path.sep); + const testDirectory = path.join(rootDirectory, 'test-recursive-regexp'); + const subDirectory = path.join(testDirectory, 'nested'); + fs.mkdirSync(testDirectory); + fs.mkdirSync(subDirectory); + + const testFile = path.join(subDirectory, 'keep.txt'); + const ignoredFile = path.join(subDirectory, 'temp.tmp'); + + const watcher = fs.watch(testDirectory, { + recursive: true, + ignore: /\.tmp$/, + }); + + let seenFile = false; + let seenIgnored = false; + let interval; + + watcher.on('change', common.mustCallAtLeast((event, filename) => { + if (filename && filename.endsWith('keep.txt')) { + seenFile = true; + } + if (filename && filename.endsWith('temp.tmp')) { + seenIgnored = true; + } + if (seenFile) { + clearInterval(interval); + interval = null; + watcher.close(); + } + }, 1)); + + // Use setInterval to handle potential event delays on macOS FSEvents + process.nextTick(common.mustCall(() => { + interval = setInterval(() => { + fs.writeFileSync(ignoredFile, 'ignored'); + fs.writeFileSync(testFile, 'content-' + Date.now()); + }, 100); + })); + + process.on('exit', () => { + assert.strictEqual(interval, null); + assert.strictEqual(seenFile, true); + assert.strictEqual(seenIgnored, false); + }); +} + +// Test 3: Glob pattern with ** to match paths in subdirectories +{ + const rootDirectory = fs.mkdtempSync(testDir + path.sep); + const testDirectory = path.join(rootDirectory, 'test-recursive-globstar'); + const nodeModules = path.join(testDirectory, 'node_modules'); + const srcDir = path.join(testDirectory, 'src'); + fs.mkdirSync(testDirectory); + fs.mkdirSync(nodeModules); + fs.mkdirSync(srcDir); + + const testFile = path.join(srcDir, 'app.js'); + const ignoredFile = path.join(nodeModules, 'package.json'); + + const watcher = fs.watch(testDirectory, { + recursive: true, + ignore: '**/node_modules/**', + }); + + let seenFile = false; + let seenIgnored = false; + let interval; + + watcher.on('change', common.mustCallAtLeast((event, filename) => { + if (filename && filename.endsWith('app.js')) { + seenFile = true; + } + if (filename && filename.includes('node_modules')) { + seenIgnored = true; + } + if (seenFile) { + clearInterval(interval); + interval = null; + watcher.close(); + } + }, 1)); + + // Use setInterval to handle potential event delays on macOS FSEvents + process.nextTick(common.mustCall(() => { + interval = setInterval(() => { + fs.writeFileSync(ignoredFile, '{}'); + fs.writeFileSync(testFile, 'console.log("hello-' + Date.now() + '")'); + }, 100); + })); + + process.on('exit', () => { + assert.strictEqual(interval, null); + assert.strictEqual(seenFile, true); + assert.strictEqual(seenIgnored, false); + }); +} + +// Test 4: Array of mixed patterns with recursive watch +{ + const rootDirectory = fs.mkdtempSync(testDir + path.sep); + const testDirectory = path.join(rootDirectory, 'test-recursive-array'); + const subDirectory = path.join(testDirectory, 'deep'); + fs.mkdirSync(testDirectory); + fs.mkdirSync(subDirectory); + + const testFile = path.join(subDirectory, 'visible.txt'); + const ignoredLog = path.join(subDirectory, 'debug.log'); + const ignoredTmp = path.join(subDirectory, 'temp.tmp'); + const ignoredHidden = path.join(subDirectory, '.gitignore'); + + const watcher = fs.watch(testDirectory, { + recursive: true, + ignore: [ + '*.log', + /\.tmp$/, + (filename) => path.basename(filename).startsWith('.'), + ], + }); + + let seenFile = false; + let seenLog = false; + let seenTmp = false; + let seenHidden = false; + let interval; + + watcher.on('change', common.mustCallAtLeast((event, filename) => { + if (filename && filename.endsWith('visible.txt')) { + seenFile = true; + } + if (filename && filename.endsWith('debug.log')) seenLog = true; + if (filename && filename.endsWith('temp.tmp')) seenTmp = true; + if (filename && filename.endsWith('.gitignore')) seenHidden = true; + + if (seenFile) { + clearInterval(interval); + interval = null; + watcher.close(); + } + }, 1)); + + // Use setInterval to handle potential event delays on macOS FSEvents + process.nextTick(common.mustCall(() => { + interval = setInterval(() => { + fs.writeFileSync(ignoredLog, 'ignored'); + fs.writeFileSync(ignoredTmp, 'ignored'); + fs.writeFileSync(ignoredHidden, 'ignored'); + fs.writeFileSync(testFile, 'content-' + Date.now()); + }, 100); + })); + + process.on('exit', () => { + assert.strictEqual(interval, null); + assert.strictEqual(seenFile, true); + assert.strictEqual(seenLog, false); + assert.strictEqual(seenTmp, false); + assert.strictEqual(seenHidden, false); + }); +} diff --git a/test/parallel/test-fs-watch-ignore.js b/test/parallel/test-fs-watch-ignore.js new file mode 100644 index 00000000000000..9f068c62b99d80 --- /dev/null +++ b/test/parallel/test-fs-watch-ignore.js @@ -0,0 +1,218 @@ +'use strict'; + +const common = require('../common'); + +if (common.isIBMi) + common.skip('IBMi does not support `fs.watch()`'); + +if (common.isAIX) + common.skip('folder watch capability is limited in AIX.'); + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); + +const tmpdir = require('../common/tmpdir'); +const testDir = tmpdir.path; +tmpdir.refresh(); + +// Test 1: String glob pattern ignore +{ + const rootDirectory = fs.mkdtempSync(testDir + path.sep); + const testDirectory = path.join(rootDirectory, 'test-glob'); + fs.mkdirSync(testDirectory); + + const testFile = path.join(testDirectory, 'file.txt'); + const ignoredFile = path.join(testDirectory, 'file.log'); + + const watcher = fs.watch(testDirectory, { + ignore: '*.log', + }); + + let seenFile = false; + let seenIgnored = false; + watcher.on('change', common.mustCallAtLeast((event, filename) => { + if (filename === 'file.txt') { + seenFile = true; + } + if (filename === 'file.log') { + seenIgnored = true; + } + // Close watcher after we've seen the non-ignored file + if (seenFile) { + watcher.close(); + } + }, 1)); + + setTimeout(() => { + fs.writeFileSync(ignoredFile, 'ignored'); + fs.writeFileSync(testFile, 'content'); + }, common.platformTimeout(200)); + + process.on('exit', () => { + assert.strictEqual(seenFile, true); + assert.strictEqual(seenIgnored, false); + }); +} + +// Test 2: RegExp ignore pattern +{ + const rootDirectory = fs.mkdtempSync(testDir + path.sep); + const testDirectory = path.join(rootDirectory, 'test-regexp'); + fs.mkdirSync(testDirectory); + + const testFile = path.join(testDirectory, 'keep.txt'); + const ignoredFile = path.join(testDirectory, 'ignore.tmp'); + + const watcher = fs.watch(testDirectory, { + ignore: /\.tmp$/, + }); + + let seenFile = false; + let seenIgnored = false; + watcher.on('change', common.mustCallAtLeast((event, filename) => { + if (filename === 'keep.txt') { + seenFile = true; + } + if (filename === 'ignore.tmp') { + seenIgnored = true; + } + if (seenFile) { + watcher.close(); + } + }, 1)); + + setTimeout(() => { + fs.writeFileSync(ignoredFile, 'ignored'); + fs.writeFileSync(testFile, 'content'); + }, common.platformTimeout(200)); + + process.on('exit', () => { + assert.strictEqual(seenFile, true); + assert.strictEqual(seenIgnored, false); + }); +} + +// Test 3: Function ignore pattern +{ + const rootDirectory = fs.mkdtempSync(testDir + path.sep); + const testDirectory = path.join(rootDirectory, 'test-function'); + fs.mkdirSync(testDirectory); + + const testFile = path.join(testDirectory, 'visible.txt'); + const ignoredFile = path.join(testDirectory, '.hidden'); + + const watcher = fs.watch(testDirectory, { + ignore: (filename) => filename.startsWith('.'), + }); + + let seenFile = false; + let seenIgnored = false; + watcher.on('change', common.mustCallAtLeast((event, filename) => { + if (filename === 'visible.txt') { + seenFile = true; + } + if (filename === '.hidden') { + seenIgnored = true; + } + if (seenFile) { + watcher.close(); + } + }, 1)); + + setTimeout(() => { + fs.writeFileSync(ignoredFile, 'ignored'); + fs.writeFileSync(testFile, 'content'); + }, common.platformTimeout(200)); + + process.on('exit', () => { + assert.strictEqual(seenFile, true); + assert.strictEqual(seenIgnored, false); + }); +} + +// Test 4: Array of mixed patterns +{ + const rootDirectory = fs.mkdtempSync(testDir + path.sep); + const testDirectory = path.join(rootDirectory, 'test-array'); + fs.mkdirSync(testDirectory); + + const testFile = path.join(testDirectory, 'keep.txt'); + const ignoredLog = path.join(testDirectory, 'debug.log'); + const ignoredTmp = path.join(testDirectory, 'temp.tmp'); + const ignoredHidden = path.join(testDirectory, '.secret'); + + const watcher = fs.watch(testDirectory, { + ignore: [ + '*.log', + /\.tmp$/, + (filename) => filename.startsWith('.'), + ], + }); + + let seenFile = false; + let seenLog = false; + let seenTmp = false; + let seenHidden = false; + watcher.on('change', common.mustCallAtLeast((event, filename) => { + if (filename === 'keep.txt') { + seenFile = true; + } + if (filename === 'debug.log') seenLog = true; + if (filename === 'temp.tmp') seenTmp = true; + if (filename === '.secret') seenHidden = true; + + if (seenFile) { + watcher.close(); + } + }, 1)); + + setTimeout(() => { + fs.writeFileSync(ignoredLog, 'ignored'); + fs.writeFileSync(ignoredTmp, 'ignored'); + fs.writeFileSync(ignoredHidden, 'ignored'); + fs.writeFileSync(testFile, 'content'); + }, common.platformTimeout(200)); + + process.on('exit', () => { + assert.strictEqual(seenFile, true); + assert.strictEqual(seenLog, false); + assert.strictEqual(seenTmp, false); + assert.strictEqual(seenHidden, false); + }); +} + +// Test 5: Invalid option validation +{ + assert.throws( + () => fs.watch('.', { ignore: 123 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + } + ); + + assert.throws( + () => fs.watch('.', { ignore: '' }), + { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + } + ); + + assert.throws( + () => fs.watch('.', { ignore: [123] }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + } + ); + + assert.throws( + () => fs.watch('.', { ignore: [''] }), + { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + } + ); +}