From b47fa2b068a6c293610c584a930b7fbb5177a888 Mon Sep 17 00:00:00 2001 From: TheThing Date: Sat, 2 Sep 2023 08:23:26 +0000 Subject: [PATCH] watcher: Imported node-watch into the project including tests. --- .gitignore | 2 + .npmrc | 1 + lib/eltro.d.ts | 47 + lib/eltro.mjs | 47 +- lib/watch/LICENSE | 22 + lib/watch/has-native-recursive.mjs | 114 +++ lib/watch/index.mjs | 549 +++++++++++ lib/watch/is.mjs | 74 ++ package.json | 2 +- test/cli.test.mjs | 62 +- test/eltro.test.mjs | 18 + .../folder1/sampletest1.temp.mjs | 0 .../folder1/sampletest2.temp.mjs | 0 .../folder2/sampletest3.temp.mjs | 0 .../folder2/sampletest4.temp.mjs | 0 .../folder2/sampletest5.temp.txt | 0 test/watch.test.mjs | 862 ++++++++++++++++++ test/watch/builder.mjs | 234 +++++ 18 files changed, 1999 insertions(+), 35 deletions(-) create mode 100644 .npmrc create mode 100644 lib/eltro.d.ts create mode 100644 lib/watch/LICENSE create mode 100644 lib/watch/has-native-recursive.mjs create mode 100644 lib/watch/index.mjs create mode 100644 lib/watch/is.mjs rename test/{ => testtree}/folder1/sampletest1.temp.mjs (100%) rename test/{ => testtree}/folder1/sampletest2.temp.mjs (100%) rename test/{ => testtree}/folder2/sampletest3.temp.mjs (100%) rename test/{ => testtree}/folder2/sampletest4.temp.mjs (100%) rename test/{ => testtree}/folder2/sampletest5.temp.txt (100%) create mode 100644 test/watch.test.mjs create mode 100644 test/watch/builder.mjs diff --git a/.gitignore b/.gitignore index 6704566..d882d2b 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +test/watch/__TREE__ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9cf9495 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/lib/eltro.d.ts b/lib/eltro.d.ts new file mode 100644 index 0000000..21c256d --- /dev/null +++ b/lib/eltro.d.ts @@ -0,0 +1,47 @@ +declare interface Stats { + passed: number + failed: number + skipped: number +} + +declare class Eltro { + __timeout: number + hasExclusive: boolean + reporter: 'dot' | 'list' + Eltro: Eltro + fileGroupMap: Map + groups: Array + activeGroup: Group | null + failedTests: Array + hasTests: boolean + starting: boolean + ignoreOnly: boolean + logger: null | { log: function(...param): void } + filename: string + prefix: string + temporary: { timeout: number, skip: boolean, only: boolean } + describeTemporary: { timeout: number, skip: boolean, only: boolean } + + __runTest(stats: Stats, test: Test, prefix: string = 'Test', child?: Test | null = null) + __runGroup(group: Group, stats: Stats) + begin() + run() + setFilename(filename: string) + resetFilename(filename: string) + + before(fn: (done?: Function) => void | Promise) + after(fn: (done?: Function) => void | Promise) + beforeEach(fn: (done?: Function) => void | Promise) + afterEach(fn: (done?: Function) => void | Promise) + describe(name: string, fn: Function) +} + +declare class Test { + +} + +declare class Group { + +} + +export default new Eltro() \ No newline at end of file diff --git a/lib/eltro.mjs b/lib/eltro.mjs index 688c420..dcae736 100644 --- a/lib/eltro.mjs +++ b/lib/eltro.mjs @@ -1,3 +1,4 @@ +/// import * as readline from 'readline' import { runWithCallbackSafe } from './callback.mjs' import { printError } from './cli.mjs' @@ -63,6 +64,13 @@ function Test(e, group, name, func) { this.name = name this.func = func this.error = null + this.startTime = null + this.totalTime = 0 +} + +Test.prototype.calculateTime = function() { + let end = process.hrtime(this.startTime) + this.totalTime = (end[0] * 1000 + Math.round(end[1] / 1000000)) } Test.prototype.timeout = function(time) { @@ -89,6 +97,25 @@ Test.prototype.clone = function(prefix = '') { return t } +let stack = [] +function captureUnknownErrors(e) { + stack.push(e) +} +function cancelCaptureUnknown(e) { + stack.splice(stack.indexOf(e), 1) +} + +process.on('uncaughtException', function(err) { + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i].captureOutsideExceptions) { + stack[i].captureOutsideExceptions(err) + return + } + } + console.error('-- UNCAUGHT EXCPEPTION OUTSIDE OF TEST RUNNER --') + console.error(err) +}) + function Eltro() { this.__timeout = 2000 this.hasExclusive = false @@ -114,6 +141,7 @@ function Eltro() { skip: false, only: false } + this.captureOutsideExceptions = null } Eltro.prototype.begin = function() { @@ -137,6 +165,7 @@ Eltro.prototype.__runTest = async function(stats, test, prefix = 'Test', child = if (!test.skipTest) { let err = await new Promise((resolve, reject) => { + this.captureOutsideExceptions = reject // Flag to check if we finished let finished = false let timeout = test.customTimeout || this.__timeout @@ -144,6 +173,8 @@ Eltro.prototype.__runTest = async function(stats, test, prefix = 'Test', child = // Timeout timer in case test times out let timer = setTimeout(function() { if (finished === true) return + + test.calculateTime() reject(new Error('timeout of ' + timeout + 'ms exceeded. Ensure the done() callback is being called in this test.')) }, timeout) @@ -153,6 +184,7 @@ Eltro.prototype.__runTest = async function(stats, test, prefix = 'Test', child = let checkIsCallback = (test.func.toString()).match(/^(function)? *\([^\)]+\)/) let promise + test.startTime = process.hrtime() // If the test requires callback, wrap it in a promise where callback // either resolves or rejects that promise if (checkIsCallback) { @@ -170,6 +202,7 @@ Eltro.prototype.__runTest = async function(stats, test, prefix = 'Test', child = // check if our test had already finished and if so, do nothing if (finished === true) return + test.calculateTime() finished = true clearTimeout(timer) resolve() @@ -177,6 +210,7 @@ Eltro.prototype.__runTest = async function(stats, test, prefix = 'Test', child = // check if our test had already finished and if so, do nothing if (finished === true) return + test.calculateTime() finished = true clearTimeout(timer) reject(err) @@ -185,6 +219,7 @@ Eltro.prototype.__runTest = async function(stats, test, prefix = 'Test', child = // check if our test had already finished and if so, do nothing if (finished === true) return + test.calculateTime() // Possible this was a synchronous test, pass immediately finished = true clearTimeout(timer) @@ -194,6 +229,7 @@ Eltro.prototype.__runTest = async function(stats, test, prefix = 'Test', child = // check if our test had already finished and if so, do nothing if (finished === true) return + test.calculateTime() // An error occured while running function. Possible exception // during a synchronous test or something else. finished = true @@ -232,15 +268,17 @@ Eltro.prototype.__runTest = async function(stats, test, prefix = 'Test', child = stats.skipped++ } + this.captureOutsideExceptions = null + if (this.reporter === 'list') { readline.clearLine(process.stdout, 0) readline.cursorTo(process.stdout, 0, null) if (markRealTest.skipTest) { process.stdout.write(' \x1b[94m- ' + markRealTest.name + '\x1b[0m\n') } else if (!markRealTest.error) { - process.stdout.write(' \x1b[32m√\x1b[90m ' + markRealTest.name + '\x1b[0m\n') + process.stdout.write(' \x1b[32m√\x1b[90m ' + markRealTest.name + ' (' + markRealTest.totalTime + 'ms)\x1b[0m\n') } else if (prefix === 'Test') { - process.stdout.write(' \x1b[31m' + this.failedTests.length + ') ' + markRealTest.name + '\x1b[0m\n') + process.stdout.write(' \x1b[31m' + this.failedTests.length + ') ' + markRealTest.name + ' (' + markRealTest.totalTime + 'ms)\x1b[0m\n') } } else if (this.reporter === 'dot') { if (markRealTest.skipTest) { @@ -302,6 +340,8 @@ Eltro.prototype.run = async function() { console.log('') } + captureUnknownErrors(this) + let stats = { passed: 0, failed: 0, @@ -314,9 +354,10 @@ Eltro.prototype.run = async function() { await this.__runGroup(this.groups[i], stats) } } - let end = process.hrtime(start) + cancelCaptureUnknown(this) + if (this.reporter === 'test') { if (this.logger && this.logger.log) { if (this.failedTests.length) { diff --git a/lib/watch/LICENSE b/lib/watch/LICENSE new file mode 100644 index 0000000..725e344 --- /dev/null +++ b/lib/watch/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2012-2021 Yuan Chuan + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/lib/watch/has-native-recursive.mjs b/lib/watch/has-native-recursive.mjs new file mode 100644 index 0000000..70d081f --- /dev/null +++ b/lib/watch/has-native-recursive.mjs @@ -0,0 +1,114 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import * as is from './is.mjs' + +let IS_SUPPORT +let TEMP_DIR = os.tmpdir && os.tmpdir() + || process.env.TMPDIR + || process.env.TEMP + || process.cwd() + +function TempStack() { + this.stack = [] +} + + +TempStack.prototype.create = function(type, base) { + let name = path.join(base, + 'node-watch-' + Math.random().toString(16).substr(2) + ) + this.stack.push({ name: name, type: type }) + return name +} +TempStack.prototype.write = function(/* file */) { + for (let i = 0; i < arguments.length; ++i) { + fs.writeFileSync(arguments[i], ' ') + } +} +TempStack.prototype.mkdir = function(/* dirs */) { + for (let i = 0; i < arguments.length; ++i) { + fs.mkdirSync(arguments[i]) + } +} +TempStack.prototype.cleanup = function(fn) { + try { + let temp + while ((temp = this.stack.pop())) { + let type = temp.type + let name = temp.name + if (type === 'file' && is.file(name)) { + fs.unlinkSync(name) + } + else if (type === 'dir' && is.directory(name)) { + fs.rmdirSync(name) + } + } + } + finally { + if (is.func(fn)) fn() + } +} + +let pending = false + +export default function hasNativeRecursive(fn) { + if (!is.func(fn)) { + return false + } + if (IS_SUPPORT !== undefined) { + return fn(IS_SUPPORT) + } + + if (!pending) { + pending = true + } + // check again later + else { + return setTimeout(function() { + hasNativeRecursive(fn) + }, 300) + } + + let stack = new TempStack() + let parent = stack.create('dir', TEMP_DIR) + let child = stack.create('dir', parent) + let file = stack.create('file', child) + + stack.mkdir(parent, child) + + let options = { recursive: true } + let watcher + + try { + watcher = fs.watch(parent, options) + } catch (e) { + if (e.code == 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') { + return fn(IS_SUPPORT = false) + } else { + throw e + } + } + + if (!watcher) { + return false + } + + let timer = setTimeout(function() { + watcher.close() + stack.cleanup(function() { + fn(IS_SUPPORT = false) + }) + }, 200) + + watcher.on('change', function(evt, name) { + if (path.basename(file) === path.basename(name)) { + watcher.close() + clearTimeout(timer) + stack.cleanup(function() { + fn(IS_SUPPORT = true) + }) + } + }) + stack.write(file) +} \ No newline at end of file diff --git a/lib/watch/index.mjs b/lib/watch/index.mjs new file mode 100644 index 0000000..1779dd3 --- /dev/null +++ b/lib/watch/index.mjs @@ -0,0 +1,549 @@ +import fs from 'fs' +import path from 'path' +import util from 'util' +import events from 'events' +import hasNativeRecursive from './has-native-recursive.mjs' +import * as is from './is.mjs' + +const EVENT_UPDATE = 'update'; +const EVENT_REMOVE = 'remove'; + +const SKIP_FLAG = Symbol('skip'); + +function hasDup(arr) { + return arr.some(function(v, i, self) { + return self.indexOf(v) !== i; + }); +} + +function unique(arr) { + return arr.filter(function(v, i, self) { + return self.indexOf(v) === i; + }); +} + +// One level flat +function flat1(arr) { + return arr.reduce(function(acc, v) { + return acc.concat(v); + }, []); +} + +function assertEncoding(encoding) { + if (encoding && encoding !== 'buffer' && !Buffer.isEncoding(encoding)) { + throw new Error('Unknown encoding: ' + encoding); + } +} + +function guard(fn) { + if (is.func(fn)) { + return function(arg, action) { + if (fn(arg, false)) action(); + } + } + if (is.regExp(fn)) { + return function(arg, action) { + if (fn.test(arg)) action(); + } + } + return function(arg, action) { + action(); + } +} + +function composeMessage(names) { + return names.map(function(n) { + return is.exists(n) + ? [EVENT_UPDATE, n] + : [EVENT_REMOVE, n]; + }); +} + +function getMessages(cache) { + var filtered = unique(cache); + + // Saving file from an editor? If so, assuming the + // non-existed files in the cache are temporary files + // generated by an editor and thus be filtered. + var reg = /~$|^\.#|^##$/g; + var hasSpecialChar = cache.some(function(c) { + return reg.test(c); + }); + + if (hasSpecialChar) { + var dup = hasDup(cache.map(function(c) { + return c.replace(reg, ''); + })); + if (dup) { + filtered = filtered.filter(function(m) { + return is.exists(m); + }); + } + } + + return composeMessage(filtered); +} + +function debounce(info, fn) { + var timer, cache = []; + var encoding = info.options.encoding; + var delay = info.options.delay; + if (!is.number(delay)) { + delay = 200; + } + function handle() { + getMessages(cache).forEach(function(msg) { + msg[1] = Buffer.from(msg[1]); + if (encoding !== 'buffer') { + msg[1] = msg[1].toString(encoding); + } + fn.apply(null, msg); + }); + timer = null; + cache = []; + } + return function(rawEvt, name) { + cache.push(name); + if (!timer) { + timer = setTimeout(handle, delay); + } + } +} + +function createDupsFilter() { + var memo = {}; + return function(fn) { + return function(evt, name) { + memo[evt + name] = [evt, name]; + setTimeout(function() { + Object.keys(memo).forEach(function(n) { + fn.apply(null, memo[n]); + }); + memo = {}; + }); + } + } +} + +function tryWatch(watcher, dir, opts) { + try { + return fs.watch(dir, opts); + } catch (e) { + process.nextTick(function() { + watcher.emit('error', e); + }); + } +} + +function getSubDirectories(dir, fn, done = function() {}) { + if (is.directory(dir)) { + fs.readdir(dir, function(err, all) { + if (err) { + // don't throw permission errors. + if (/^(EPERM|EACCES)$/.test(err.code)) { + console.warn('Warning: Cannot access %s.', dir); + } else { + throw err; + } + } + else { + all.forEach(function(f) { + var sdir = path.join(dir, f); + if (is.directory(sdir)) fn(sdir); + }); + done(); + } + }); + } else { + done(); + } +} + +function semaphore(final) { + var counter = 0; + return function start() { + counter++; + return function stop() { + counter--; + if (counter === 0) final(); + }; + }; +} + +function nullCounter() { + return function nullStop() {}; +} + +function shouldNotSkip(filePath, filter) { + // watch it only if the filter is not function + // or not being skipped explicitly. + return !is.func(filter) || filter(filePath, SKIP_FLAG) !== SKIP_FLAG; +} + +var deprecationWarning = util.deprecate( + function() {}, + '(node-watch) First param in callback function\ + is replaced with event name since 0.5.0, use\ + `(evt, filename) => {}` if you want to get the filename' +); + +function Watcher() { + events.EventEmitter.call(this); + this.watchers = {}; + this._isReady = false; + this._isClosed = false; +} + +util.inherits(Watcher, events.EventEmitter); + +Watcher.prototype.expose = function() { + var expose = {}; + var self = this; + var methods = [ + 'on', 'emit', 'once', + 'close', 'isClosed', + 'listeners', 'setMaxListeners', 'getMaxListeners', + 'getWatchedPaths' + ]; + methods.forEach(function(name) { + expose[name] = function() { + return self[name].apply(self, arguments); + } + }); + expose.options = self.options + return expose; +} + +Watcher.prototype.isClosed = function() { + return this._isClosed; +} + +Watcher.prototype.close = function(fullPath) { + var self = this; + if (fullPath) { + var watcher = this.watchers[fullPath]; + if (watcher && watcher.close) { + watcher.close(); + delete self.watchers[fullPath]; + } + getSubDirectories(fullPath, function(fpath) { + self.close(fpath); + }); + } + else { + Object.keys(self.watchers).forEach(function(fpath) { + var watcher = self.watchers[fpath]; + if (watcher && watcher.close) { + watcher.close(); + } + }); + this.watchers = {}; + } + // Do not close the Watcher unless all child watchers are closed. + // https://github.com/yuanchuan/node-watch/issues/75 + if (is.emptyObject(self.watchers)) { + // should emit once + if (!this._isClosed) { + this._isClosed = true; + process.nextTick(emitClose, this); + } + } +} + +Watcher.prototype.getWatchedPaths = function(fn) { + if (is.func(fn)) { + var self = this; + if (self._isReady) { + fn(Object.keys(self.watchers)); + } else { + self.on('ready', function() { + fn(Object.keys(self.watchers)); + }); + } + } +} + +function emitReady(self) { + if (!self._isReady) { + self._isReady = true; + // do not call emit for 'ready' until after watch() has returned, + // so that consumer can call on(). + process.nextTick(function () { + self.emit('ready'); + }); + } +} + +function emitClose(self) { + self.emit('close'); +} + +Watcher.prototype.add = function(watcher, info) { + var self = this; + info = info || { fpath: '' }; + var watcherPath = path.resolve(info.fpath); + this.watchers[watcherPath] = watcher; + + // Internal callback for handling fs.FSWatcher 'change' events + var internalOnChange = function(rawEvt, rawName) { + if (self.isClosed()) { + return; + } + + // normalise lack of name and convert to full path + var name = rawName; + if (is.nil(name)) { + name = ''; + } + name = path.join(info.fpath, name); + + if (info.options.recursive) { + hasNativeRecursive(function(has) { + if (!has) { + var fullPath = path.resolve(name); + // remove watcher on removal + if (!is.exists(name)) { + self.close(fullPath); + } + // watch new created directory + else { + var shouldWatch = is.directory(name) + && !self.watchers[fullPath] + && shouldNotSkip(name, info.options.filter); + + if (shouldWatch) { + self.watchDirectory(name, info.options); + } + } + } + }); + } + + handlePublicEvents(rawEvt, name); + }; + + // Debounced based on the 'delay' option + var handlePublicEvents = debounce(info, function (evt, name) { + // watch single file + if (info.compareName) { + if (info.compareName(name)) { + self.emit('change', evt, name); + } + } + // watch directory + else { + var filterGuard = guard(info.options.filter); + filterGuard(name, function() { + if (self.flag) self.flag = ''; + else self.emit('change', evt, name); + }); + } + }); + + watcher.on('error', function(err) { + if (self.isClosed()) { + return; + } + if (is.windows() && err.code === 'EPERM') { + watcher.emit('change', EVENT_REMOVE, info.fpath && ''); + self.flag = 'windows-error'; + self.close(watcherPath); + } else { + self.emit('error', err); + } + }); + + watcher.on('change', internalOnChange); +} + +Watcher.prototype.watchFile = function(file, options, fn) { + var parent = path.join(file, '../'); + var opts = Object.assign({}, options, { + // no filter for single file + filter: null, + encoding: 'utf8' + }); + + // no need to watch recursively + delete opts.recursive; + + var watcher = tryWatch(this, parent, opts); + if (!watcher) { + return; + } + + this.add(watcher, { + type: 'file', + fpath: parent, + options: Object.assign({}, opts, { + encoding: options.encoding + }), + compareName: function(n) { + return is.samePath(n, file); + } + }); + + if (is.func(fn)) { + if (fn.length === 1) deprecationWarning(); + this.on('change', fn); + } +} + +Watcher.prototype.updateDelay = function(delay) { + console.log() +} + +Watcher.prototype.watchDirectory = function(dir, options, fn, counter = nullCounter) { + var self = this; + var done = counter(); + hasNativeRecursive(function(has) { + // always specify recursive + options.recursive = !!options.recursive; + // using utf8 internally + var opts = Object.assign({}, options, { + encoding: 'utf8' + }); + if (!has) { + delete opts.recursive; + } + + // check if it's closed before calling watch. + if (self._isClosed) { + done(); + return self.close(); + } + + var watcher = tryWatch(self, dir, opts); + if (!watcher) { + done(); + return; + } + + self.add(watcher, { + type: 'dir', + fpath: dir, + options: options + }); + + if (is.func(fn)) { + if (fn.length === 1) deprecationWarning(); + self.on('change', fn); + } + + if (options.recursive && !has) { + getSubDirectories(dir, function(d) { + if (shouldNotSkip(d, options.filter)) { + self.watchDirectory(d, options, null, counter); + } + }, counter()); + } + + done(); + }); +} + +function composeWatcher(watchers) { + var watcher = new Watcher(); + var filterDups = createDupsFilter(); + var counter = watchers.length; + + watchers.forEach(function(w) { + w.on('change', filterDups(function(evt, name) { + watcher.emit('change', evt, name); + })); + w.on('error', function(err) { + watcher.emit('error', err); + }); + w.on('ready', function() { + if (!(--counter)) { + emitReady(watcher); + } + }); + }); + + watcher.close = function() { + watchers.forEach(function(w) { + w.close(); + }); + process.nextTick(emitClose, watcher); + } + + watcher.getWatchedPaths = function(fn) { + if (is.func(fn)) { + var promises = watchers.map(function(w) { + return new Promise(function(resolve) { + w.getWatchedPaths(resolve); + }); + }); + Promise.all(promises).then(function(result) { + var ret = unique(flat1(result)); + fn(ret); + }); + } + } + + return watcher.expose(); +} + +export default function watch(fpath, options, fn) { + var watcher = new Watcher(); + + if (is.buffer(fpath)) { + fpath = fpath.toString(); + } + + if (!is.array(fpath) && !is.exists(fpath)) { + process.nextTick(function() { + watcher.emit('error', + new Error(fpath + ' does not exist.') + ); + }); + } + + if (is.string(options)) { + throw new Error(`Invalid option, encoding as string is no longer supported. Use { encoding: "${options}" } instead.`) + } + + if (is.func(options)) { + fn = options; + options = {}; + } + + if (arguments.length < 2) { + options = {}; + } + + if (options.encoding) { + assertEncoding(options.encoding); + } else { + options.encoding = 'utf8'; + } + + if (is.array(fpath)) { + if (fpath.length === 1) { + return watch(fpath[0], options, fn); + } + var filterDups = createDupsFilter(); + return composeWatcher(unique(fpath).map(function(f) { + var w = watch(f, options); + if (is.func(fn)) { + w.on('change', filterDups(fn)); + } + return w; + })); + } + + if (is.file(fpath)) { + watcher.watchFile(fpath, options, fn); + emitReady(watcher); + } + + else if (is.directory(fpath)) { + var counter = semaphore(function () { + emitReady(watcher); + }); + watcher.watchDirectory(fpath, options, fn, counter); + } + + return watcher.expose(); +} diff --git a/lib/watch/is.mjs b/lib/watch/is.mjs new file mode 100644 index 0000000..947603a --- /dev/null +++ b/lib/watch/is.mjs @@ -0,0 +1,74 @@ +import fs from 'fs' +import path from 'path' +import os from 'os' + +function matchObject(item, str) { + return Object.prototype.toString.call(item) + === '[object ' + str + ']' +} + +function checkStat(name, fn) { + try { + return fn(name) + } catch (err) { + if (/^(ENOENT|EPERM|EACCES)$/.test(err.code)) { + if (err.code !== 'ENOENT') { + console.warn('Warning: Cannot access %s', name) + } + return false + } + throw err + } +} + +export function nil(item) { + return item == null +} +export function array(item) { + return Array.isArray(item) +} +export function emptyObject(item) { + for (var key in item) { + return false + } + return true +} +export function buffer(item) { + return Buffer.isBuffer(item) +} +export function regExp(item) { + return matchObject(item, 'RegExp') +} +export function string(item) { + return matchObject(item, 'String') +} +export function func(item) { + return typeof item === 'function' +} +export function number(item) { + return matchObject(item, 'Number') +} +export function exists(name) { + return fs.existsSync(name) +} +export function file(name) { + return checkStat(name, function(n) { + return fs.statSync(n).isFile() + }) +} +export function samePath(a, b) { + return path.resolve(a) === path.resolve(b) +} +export function directory(name) { + return checkStat(name, function(n) { + return fs.statSync(n).isDirectory() + }) +} +export function symbolicLink(name) { + return checkStat(name, function(n) { + return fs.lstatSync(n).isSymbolicLink() + }) +} +export function windows() { + return os.platform() === 'win32' +} diff --git a/package.json b/package.json index a6dae7f..6e0bc39 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Eltro is a tiny no-dependancy test framework for node", "main": "index.mjs", "scripts": { - "test": "node cli.mjs test/**/*.test.mjs", + "test": "node cli.mjs 'test/**/*.test.mjs'", "test:watch": "npm-watch test" }, "watch": { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index f89a953..38f745e 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -124,7 +124,7 @@ t.describe('CLI', function() { t.describe('#processTargets()', function() { t.test('should mark errored if empty', async function() { - cli.targets = ['test/folder1/*.txt'] + cli.targets = ['test/testtree/folder1/*.txt'] await cli.processTargets() assert.strictEqual(cli.files.length, 0) @@ -132,31 +132,31 @@ t.describe('CLI', function() { }) t.test('should support direct file path if exists', async function() { - cli.targets = ['test/folder1/sampletest1.temp.mjs'] + cli.targets = ['test/testtree/folder1/sampletest1.temp.mjs'] await cli.processTargets() assert.strictEqual(cli.files.length, 1) - assert.strictEqual(cli.files[0], 'test/folder1/sampletest1.temp.mjs') + assert.strictEqual(cli.files[0], 'test/testtree/folder1/sampletest1.temp.mjs') }) t.test('should return all files in a directory', async function() { - cli.targets = ['test/folder1/'] + cli.targets = ['test/testtree/folder1/'] await cli.processTargets() assert.strictEqual(cli.files.length, 2) cli.files.sort() - assert.strictEqual(cli.files[0], 'test/folder1/sampletest1.temp.mjs') - assert.strictEqual(cli.files[1], 'test/folder1/sampletest2.temp.mjs') + assert.strictEqual(cli.files[0], 'test/testtree/folder1/sampletest1.temp.mjs') + assert.strictEqual(cli.files[1], 'test/testtree/folder1/sampletest2.temp.mjs') }) t.test('should support start as folder substitute', async function() { - cli.targets = ['*/folder1/'] + cli.targets = ['*/testtree/folder1/'] await cli.processTargets() assert.strictEqual(cli.files.length, 2) cli.files.sort() - assert.strictEqual(cli.files[0], 'test/folder1/sampletest1.temp.mjs') - assert.strictEqual(cli.files[1], 'test/folder1/sampletest2.temp.mjs') + assert.strictEqual(cli.files[0], 'test/testtree/folder1/sampletest1.temp.mjs') + assert.strictEqual(cli.files[1], 'test/testtree/folder1/sampletest2.temp.mjs') }) t.test('should support grabbing only files in folder', async function() { @@ -182,28 +182,28 @@ t.describe('CLI', function() { }) t.test('should support multiple star pattern', async function() { - cli.targets = ['test/*/*.mjs'] + cli.targets = ['test/testtree/*/*.mjs'] await cli.processTargets() - + + console.log(cli.files) assert.strictEqual(cli.files.length, 4) cli.files.sort() assert.deepEqual(cli.files, [ - 'test/folder1/sampletest1.temp.mjs', - 'test/folder1/sampletest2.temp.mjs', - 'test/folder2/sampletest3.temp.mjs', - 'test/folder2/sampletest4.temp.mjs', + 'test/testtree/folder1/sampletest1.temp.mjs', + 'test/testtree/folder1/sampletest2.temp.mjs', + 'test/testtree/folder2/sampletest3.temp.mjs', + 'test/testtree/folder2/sampletest4.temp.mjs', ]) - cli.targets = ['test/*/sampletest*.mjs'] + cli.targets = ['test/testtree/*/sampletest*.mjs'] await cli.processTargets() - assert.strictEqual(cli.files.length, 4) cli.files.sort() assert.deepEqual(cli.files, [ - 'test/folder1/sampletest1.temp.mjs', - 'test/folder1/sampletest2.temp.mjs', - 'test/folder2/sampletest3.temp.mjs', - 'test/folder2/sampletest4.temp.mjs', + 'test/testtree/folder1/sampletest1.temp.mjs', + 'test/testtree/folder1/sampletest2.temp.mjs', + 'test/testtree/folder2/sampletest3.temp.mjs', + 'test/testtree/folder2/sampletest4.temp.mjs', ]) }) @@ -223,11 +223,11 @@ t.describe('CLI', function() { } for (let i = 0; i < cli.files.length; i++) { - found.sampletest1 = found.sampletest1 || cli.files[i] === 'test/folder1/sampletest1.temp.mjs' - found.sampletest2 = found.sampletest2 || cli.files[i] === 'test/folder1/sampletest2.temp.mjs' - found.sampletest3 = found.sampletest3 || cli.files[i] === 'test/folder2/sampletest3.temp.mjs' - found.sampletest4 = found.sampletest4 || cli.files[i] === 'test/folder2/sampletest4.temp.mjs' - found.sampletest5 = found.sampletest5 || cli.files[i] === 'test/folder2/sampletest5.temp.txt' + found.sampletest1 = found.sampletest1 || cli.files[i] === 'test/testtree/folder1/sampletest1.temp.mjs' + found.sampletest2 = found.sampletest2 || cli.files[i] === 'test/testtree/folder1/sampletest2.temp.mjs' + found.sampletest3 = found.sampletest3 || cli.files[i] === 'test/testtree/folder2/sampletest3.temp.mjs' + found.sampletest4 = found.sampletest4 || cli.files[i] === 'test/testtree/folder2/sampletest4.temp.mjs' + found.sampletest5 = found.sampletest5 || cli.files[i] === 'test/testtree/folder2/sampletest5.temp.txt' found.cli = found.cli || cli.files[i] === 'test/cli.test.mjs' } @@ -257,11 +257,11 @@ t.describe('CLI', function() { } for (let i = 0; i < cli.files.length; i++) { - found.sampletest1 = found.sampletest1 || cli.files[i] === 'test/folder1/sampletest1.temp.mjs' - found.sampletest2 = found.sampletest2 || cli.files[i] === 'test/folder1/sampletest2.temp.mjs' - found.sampletest3 = found.sampletest3 || cli.files[i] === 'test/folder2/sampletest3.temp.mjs' - found.sampletest4 = found.sampletest4 || cli.files[i] === 'test/folder2/sampletest4.temp.mjs' - found.sampletest5 = found.sampletest5 || cli.files[i] === 'test/folder2/sampletest5.temp.txt' + found.sampletest1 = found.sampletest1 || cli.files[i] === 'test/testtree/folder1/sampletest1.temp.mjs' + found.sampletest2 = found.sampletest2 || cli.files[i] === 'test/testtree/folder1/sampletest2.temp.mjs' + found.sampletest3 = found.sampletest3 || cli.files[i] === 'test/testtree/folder2/sampletest3.temp.mjs' + found.sampletest4 = found.sampletest4 || cli.files[i] === 'test/testtree/folder2/sampletest4.temp.mjs' + found.sampletest5 = found.sampletest5 || cli.files[i] === 'test/testtree/folder2/sampletest5.temp.txt' found.cli = found.cli || cli.files[i] === 'test/cli.test.mjs' } diff --git a/test/eltro.test.mjs b/test/eltro.test.mjs index 3c07a45..73a9e40 100644 --- a/test/eltro.test.mjs +++ b/test/eltro.test.mjs @@ -226,6 +226,24 @@ e.test('Eltro should support callback rejected errors', async function() { assert.strictEqual(t.failedTests[0].error, assertError) }) +e.test('Eltro should support capturing unknown errors outside scope', async function() { + testsWereRun = true + const assertError = new Error() + const t = CreateT() + t.begin() + t.describe('', function() { + t.test('', function(cb) { + process.nextTick(function() { + console.log('throw') + throw assertError + }) + }) + }) + await t.run() + assert.strictEqual(t.failedTests.length, 1) + assert.strictEqual(t.failedTests[0].error, assertError) +}) + e.test('Eltro should support timing out tests', async function() { testsWereRun = true const t = CreateT() diff --git a/test/folder1/sampletest1.temp.mjs b/test/testtree/folder1/sampletest1.temp.mjs similarity index 100% rename from test/folder1/sampletest1.temp.mjs rename to test/testtree/folder1/sampletest1.temp.mjs diff --git a/test/folder1/sampletest2.temp.mjs b/test/testtree/folder1/sampletest2.temp.mjs similarity index 100% rename from test/folder1/sampletest2.temp.mjs rename to test/testtree/folder1/sampletest2.temp.mjs diff --git a/test/folder2/sampletest3.temp.mjs b/test/testtree/folder2/sampletest3.temp.mjs similarity index 100% rename from test/folder2/sampletest3.temp.mjs rename to test/testtree/folder2/sampletest3.temp.mjs diff --git a/test/folder2/sampletest4.temp.mjs b/test/testtree/folder2/sampletest4.temp.mjs similarity index 100% rename from test/folder2/sampletest4.temp.mjs rename to test/testtree/folder2/sampletest4.temp.mjs diff --git a/test/folder2/sampletest5.temp.txt b/test/testtree/folder2/sampletest5.temp.txt similarity index 100% rename from test/folder2/sampletest5.temp.txt rename to test/testtree/folder2/sampletest5.temp.txt diff --git a/test/watch.test.mjs b/test/watch.test.mjs new file mode 100644 index 0000000..d2ef96c --- /dev/null +++ b/test/watch.test.mjs @@ -0,0 +1,862 @@ +import path from 'path' +import assert from '../lib/assert.mjs' +import t from '../lib/eltro.mjs' +import { Builder, Counter } from './watch/builder.mjs' +import watch from '../lib/watch/index.mjs' +import * as is from '../lib/watch/is.mjs' +import hasNativeRecursive from '../lib/watch/has-native-recursive.mjs' + +const builder = new Builder() +let watcher + +function htTimeToMs(end) { + return end[0] * 1000 + Math.round(end[1] / 1000000) +} + +t.before(function() { + return builder.init() +}) + +t.afterEach(function(done) { + if (watcher && !watcher.isClosed()) { + watcher.on('close', done) + watcher.close() + } else { + done() + } +}) + +t.after(function() { + if (builder) { + builder.cleanup() + } +}) + +function wait(fn, timeout) { + try { + fn() + } catch (error) { + timeout -= 30 + if (timeout >= 0) { + setTimeout(function() { + wait(fn, timeout) + }, 30) + } else { + throw error + } + } +} + +t.describe('process events', function() { + t.test('should emit `close` event', function(done) { + var file = 'home/a/file1' + var fpath = builder.getPath(file) + watcher = watch(fpath, function() {}) + watcher.on('close', done) + watcher.close() + }) + + t.test('should emit `ready` event when watching a file', function(done) { + var file = 'home/a/file1' + var fpath = builder.getPath(file) + watcher = watch(fpath) + watcher.on('ready', done) + }) + + t.test('should emit `ready` event when watching a directory recursively', function(done) { + var dir = builder.getPath('home') + watcher = watch(dir, { recursive: true }) + watcher.on('ready', done) + }) + + t.test('should emit `ready` properly in a composed watcher', function(done) { + var dir1 = builder.getPath('home/a') + var dir2 = builder.getPath('home/b') + var file = builder.getPath('home/b/file1') + watcher = watch([dir1, dir2, file], { recursive: true }) + watcher.on('ready', done) + }) +}) + +t.describe('watch for files', function() { + t.test('should watch a single file and keep watching', function(done) { + var counter = new Counter(done, 3) + var file = 'home/a/file1' + var fpath = builder.getPath(file) + watcher = watch(fpath, { delay: 0 }, function(evt, name) { + assert.strictEqual(fpath, name) + counter.count() + }) + watcher.on('ready', function() { + counter.waitForCount(builder.modify(file)) + .then(() => counter.waitForCount(builder.modify(file))) + .then(() => counter.waitForCount(builder.modify(file))) + }) + }) + + t.test('should watch files inside a directory', function(done) { + var fpath = builder.getPath('home/a') + var set = new Set() + set.add(builder.getPath('home/a/file1')) + set.add(builder.getPath('home/a/file2')) + + Promise.all([ + builder.newFile('home/a/file1'), + builder.newFile('home/a/file2'), + ]).then(() => { + watcher = watch(fpath, { delay: 0 }, function(evt, name) { + set.delete(name) + if (!set.size) { + done() + } + }) + + watcher.on('ready', function() { + Promise.all([ + builder.modify('home/a/file1'), + builder.modify('home/a/file2'), + ]).then() + }) + }) + }) + + t.test('should debounce multiple triggers', function(done) { + var counter = new Counter() + var file = 'home/a/file2' + var fpath = builder.getPath(file) + + var start = process.hrtime() + var middle = start + var end = start + + watcher = watch(fpath, { delay: 100 }, function(evt, name) { + if (fpath === name) counter.count() + }) + + watcher.on('ready', function() { + builder.modify(file) + .then(() => builder.modify(file)) + .then(() => builder.modify(file)) + .then(() => { + middle = htTimeToMs(process.hrtime(start)) + return counter.waitForCount() + }) + .then(() => { + assert.strictEqual(counter.counter, 1) + end = htTimeToMs(process.hrtime(start)) + + assert.ok(end - middle > 50) + assert.ok(end >= 100) + done() + }) + .catch(done) + }) + }) + + t.test('should listen to new created files', function(done) { + var home = builder.getPath('home') + var counter = new Counter() + var newfile1 = 'home/a/newfile' + builder.randomName() + var newfile2 = 'home/a/newfile' + builder.randomName() + var set = new Set([ + builder.getPath(newfile1), + builder.getPath(newfile2), + ]) + var changes = [] + var extra = [] + watcher = watch(home, { delay: 0, recursive: true }, function(evt, name) { + if (set.has(name)) { + changes.push(name) + counter.count() + } else { + extra.push(name) + } + }) + watcher.on('ready', function() { + counter.waitForCount(builder.newFile(newfile1)) + .then(() => counter.waitForCount(builder.newFile(newfile2))) + .then(() => { + assert.deepStrictEqual(changes, [...set.values()]) + assert.deepStrictEqual(extra, []) + done() + }) + .catch(done) + }) + }) + + t.test('should error when parent gets deleted before calling fs.watch', function(done) { + var fpath = builder.getPath('home/a/file1') + builder.newFile('home/a/file1') + .then(() => { + watcher = watch(fpath, Object.defineProperty({}, 'test', { + enumerable: true, + get: function() { + builder.removeSync('home/a') + return 'test' + } + })) + watcher.on('error', function() { + done() + }) + }) + }) +}) + +t.describe('watch for directories', function() { + t.test('should watch directories inside a directory', function(done) { + var home = builder.getPath('home') + var dir = builder.getPath('home/c') + + builder.createDirectory(dir).then(() => { + watcher = watch(home, { delay: 0, recursive: true }, function(evt, name) { + if (name === dir && evt === 'remove') { + done() + } + }) + watcher.on('ready', function() { + builder.remove('home/c').then() + }) + }) + }) + + t.test('should watch new created directories', function(done) { + var home = builder.getPath('home') + + builder.remove('home/new').then(() => { + watcher = watch(home, { delay: 0, recursive: true }, function(evt, name) { + if (name === builder.getPath('home/new/file1')) { + done() + } + }) + watcher.on('ready', function() { + builder.newFile('home/new/file1') + .then(() => builder.modify('home/new/file1')) + .catch(done) + }) + }) + }) + + t.test('should not watch new created directories which are being skipped in the filter', function(done) { + var counter = new Counter() + var home = builder.getPath('home') + + var options = { + delay: 0, + recursive: true, + filter: function(filePath, SKIP_FLAG) { + if (/ignored/.test(filePath)) { + counter.count() + return SKIP_FLAG + } + return false + } + } + + builder.remove('home/ignored/file').then(() => { + watcher = watch(home, options, function(evt, name) { + assert.fail("should not watch new created directories which are being skipped in the filter event detect: " + name) + }) + + watcher.on('ready', function() { + builder.newFile('home/ignored/file') + .then(() => { + assert.ok(counter.counter) + done() + }) + .catch(done) + }) + }) + }) + + t.test('should keep watching after removal of sub directory', function(done) { + var counter = new Counter(done, 3) + var home = builder.getPath('home') + var file1 = builder.getPath('home/e/file1') + var file2 = builder.getPath('home/e/file2') + var dir = builder.getPath('home/e/sub') + var set = new Set() + + Promise.all([ + builder.newFile('home/e/sub/testfile'), + builder.newFile('home/e/file1'), + builder.newFile('home/e/file2'), + ]).then(() => { + + watcher = watch(home, { delay: 0, recursive: true }, function(evt, name) { + if (name === dir || name === file1 || name === file2) { + if (!set.has(name)) { + set.add(name) + counter.count() + } + } + }) + + watcher.on('ready', function() { + builder.remove('home/e/sub') + builder.modify('home/e/file1') + builder.modify('home/e/file2') + }) + }) + }) + + t.test('should watch new directories without delay', function(done) { + var counter = new Counter(done, 1) + var home = builder.getPath('home') + + builder.remove('home/new').then(() => { + watcher = watch(home, { delay: 200, recursive: true }, function(evt, name) { + if (name === builder.getPath('home/new/file1')) { + counter.count() + } + }) + watcher.on('ready', function() { + builder.newFile('home/new/file1') + .then(() => builder.modify('home/new/file1')) + .then(() => builder.modify('home/new/file1')) + }) + }) + }) + + t.test('should error when directory gets deleted before calling fs.watch', function(done) { + var dir = 'home/c' + var fpath = builder.getPath(dir) + + builder.createDirectory(dir).then(() => { + watcher = watch(fpath, Object.defineProperty({}, 'test', { + enumerable: true, + get: function() { + builder.removeSync(dir) + return 'test' + } + })) + watcher.on('error', function() { + done() + }) + }) + }) +}) + +t.describe('file events', function() { + var file = 'home/a/file1' + var fpath = builder.getPath(file) + + t.beforeEach(function() { + return builder.newFile(file) + }) + + t.test('should identify `remove` event', function(done) { + watcher = watch(fpath, { delay: 0 }, function(evt, name) { + if (evt === 'remove' && name === fpath) { + done() + } + }) + watcher.on('ready', function() { + builder.remove(file).catch(done) + }) + }) + + t.test('should identify `remove` event on directory', function(done) { + var dir = 'home/a' + var home = builder.getPath('home') + var fpath = builder.getPath(dir) + + watcher = watch(home, { delay: 0 }, function(evt, name) { + if (evt === 'remove' && name === fpath) done() + }) + watcher.on('ready', function() { + builder.remove(dir).catch(done) + }) + }) + + t.test('should be able to handle many events on deleting', function(done) { + var dir = 'home/a' + var fpath = builder.getPath(dir) + + builder.newRandomFiles(dir, 100).then(names => { + var counter = new Counter(done, names.length) + + watcher = watch(fpath, { delay: 10 }, function(evt, name) { + if (evt === 'remove') counter.count() + }) + + watcher.on('ready', function() { + Promise.all(names.map(x => builder.remove(path.join(dir, x)))).catch(done) + }) + }) + }) + + t.test('should identify `update` event', function(done) { + var file = 'home/b/file1' + var fpath = builder.getPath(file) + builder.newFile(fpath).then(() => { + watcher = watch(fpath, { delay: 0 }, function(evt, name) { + if (evt === 'update' && name === fpath) done() + }) + watcher.on('ready', function() { + builder.modify(file).catch(done) + }) + }) + }) + + t.test('should report `update` on new files', function(done) { + var dir = builder.getPath('home/a') + var file = 'home/a/newfile_' + builder.randomName() + var fpath = builder.getPath(file) + + watcher = watch(dir, { delay: 0 }, function(evt, name) { + if (evt === 'update' && name === fpath) done() + }) + watcher.on('ready', function() { + builder.newFile(file).catch(done) + }) + }) +}) + +t.describe('options', function() { + t.describe('recursive', function() { + t.test('should watch recursively with `recursive: true` option', function(done) { + var dir = builder.getPath('home') + var file = builder.getPath('home/bb/file1') + watcher = watch(dir, { delay: 0, recursive: true }, function(evt, name) { + if (file === name) { + done() + } + }) + watcher.on('ready', function() { + builder.modify('home/bb/file1').catch(done) + }) + }) + }) + + t.describe('encoding', function() { + let options = { + delay: 0, + encoding: 'unknown' + }; + + var dir = 'home/a' + var fdir = builder.getPath('home/a') + var file = 'home/a/file1' + var fpath = builder.getPath(file) + + t.before(() => { + return builder.newFile(file) + }) + + t.test('should throw on invalid encoding', function(done) { + options.encoding = 'unknown' + try { + watcher = watch(fdir, options) + } catch (e) { + done() + } + }) + + t.test('should accept an encoding string', function(done) { + options.encoding = 'utf8' + watcher = watch(fdir, options, done.finish(function(evt, name) { + assert.strictEqual(name.toString(), fpath) + })) + watcher.on('ready', function() { + builder.modify(file).catch(done) + }) + }) + + t.test('should support buffer encoding', function(done) { + options.encoding = 'buffer' + watcher = watch(fdir, options, done.finish(function(evt, name) { + assert.ok(Buffer.isBuffer(name), 'not a Buffer') + assert.strictEqual(name.toString(), fpath) + })) + + watcher.on('ready', function() { + builder.modify(file).catch(done) + }) + }) + + t.test('should support base64 encoding', function(done) { + options.encoding = 'base64' + watcher = watch(fdir, options, done.finish(function(evt, name) { + assert.strictEqual( + name, + Buffer.from(fpath).toString('base64'), + 'wrong base64 encoding' + ) + })) + watcher.on('ready', function() { + builder.modify(file).catch(done) + }) + }) + + t.test('should support hex encoding', function(done) { + options.encoding = 'hex' + watcher = watch(fdir, options, done.finish(function(evt, name) { + assert.strictEqual( + name, + Buffer.from(fpath).toString('hex'), + 'wrong hex encoding' + ) + })) + watcher.on('ready', function() { + builder.modify(file).catch(done) + }) + }) + }) + + t.describe('filter', function() { + t.test('should only watch filtered directories', function(done) { + var matchRegularDir = false + var matchIgnoredDir = false + var counter = new Counter(done.finish(function() { + assert(matchRegularDir, 'watch failed to detect regular file') + assert(!matchIgnoredDir, 'fail to ignore path `deep_node_modules`') + }), 1, true) + + var options = { + delay: 0, + recursive: true, + filter: function(name) { + return !/deep_node_modules/.test(name) + } + } + + watcher = watch(builder.getPath('home'), options, function(evt, name) { + if (/deep_node_modules/.test(name)) { + matchIgnoredDir = true + } else { + matchRegularDir = true + } + counter.count() + }) + watcher.on('ready', function() { + counter.startCounting() + builder.modify('home/deep_node_modules/ma/file1') + .then(() => builder.modify('home/b/file1')) + .catch(done) + }) + }) + + t.test('should only report filtered files', function(done) { + var dir = builder.getPath('home') + var file1 = 'home/bb/file1' + var file2 = 'home/bb/file2' + + + var counter = new Counter(done.finish(function() { + assert.strictEqual(matchIgnoredFile, false, 'home/bb/file1 should be ignored') + }), 1, true) + + var options = { + delay: 0, + recursive: true, + filter: function(name) { + return /file2/.test(name) + } + } + + var times = 0 + var matchIgnoredFile = false + watcher = watch(dir, options, function(evt, name) { + if (name === builder.getPath(file1)) { + matchIgnoredFile = true + } + counter.count() + }) + watcher.on('ready', function() { + counter.startCounting() + + builder.modify(file2) + .then(() => builder.modify(file1)) + .catch(done) + }) + }) + + t.test('should be able to filter directly with regexp', function(done) { + var dir = builder.getPath('home') + var file1 = 'home/bb/file1' + var file2 = 'home/bb/file2' + + + var counter = new Counter(done.finish(function() { + assert.strictEqual(matchIgnoredFile, false, 'home/bb/file1 should be ignored') + }), 1, true) + + var options = { + delay: 0, + recursive: true, + filter: /file2/ + } + + var times = 0 + var matchIgnoredFile = false + watcher = watch(dir, options, function(evt, name) { + if (name === builder.getPath(file1)) { + matchIgnoredFile = true + } + counter.count() + }) + watcher.on('ready', function() { + counter.startCounting() + + builder.modify(file2) + .then(() => builder.modify(file1)) + .catch(done) + }) + }) + + t.test('should be able to skip subdirectories with `skip` flag', function(done) { + var home = builder.getPath('home') + var options = { + delay: 0, + recursive: true, + filter: function(name, skip) { + if (/\/deep_node_modules/.test(name)) return skip + } + } + watcher = watch(home, options) + + watcher.getWatchedPaths(function(paths) { + hasNativeRecursive(function(supportRecursive) { + var watched = supportRecursive + // The skip flag has no effect to the platforms which support recursive option, + // so the home directory is the only one that's in the watching list. + ? [home] + // The deep_node_modules and all its subdirectories should not be watched + // with skip flag specified in the filter. + : builder.getAllDirectories().filter(function(name) { + return !/\/deep_node_modules/.test(name) + }) + + assert.deepStrictEqual( + watched.sort(), paths.sort() + ) + + done() + }) + }) + }) + }) +}) + +t.describe('parameters', function() { + t.test('should throw error on non-existed file', function(done) { + var somedir = builder.getPath('home/somedir') + watcher = watch(somedir) + watcher.on('error', done.finish(function(err) { + assert.match(err.message, /not exist/) + })) + }) + + t.test('should accept filename as Buffer', function(done) { + var fpath = builder.getPath('home/a/file1') + watcher = watch(Buffer.from(fpath), { delay: 0 }, done.finish(function(evt, name) { + assert.strictEqual(name, fpath) + })) + watcher.on('ready', function() { + builder.modify('home/a/file1').catch(done) + }) + }) + + t.test('should compose array of files or directories', function(done) { + var counter = new Counter(done, 2) + var file1 = 'home/a/file1' + var file2 = 'home/a/file2' + var fpaths = [ + builder.getPath(file1), + builder.getPath(file2) + ] + + var times = 0 + watcher = watch(fpaths, { delay: 0 }, function(evt, name) { + if (fpaths.indexOf(name) !== -1) counter.count() + }) + + watcher.on('ready', function() { + Promise.all([ + builder.modify(file1), + builder.modify(file2), + ]).catch(done) + }) + }) + + t.test('should filter duplicate events for composed watcher', function(done) { + var home = 'home' + var dir = 'home/a' + var file1 = 'home/a/file1' + var file2 = 'home/a/file2' + var fpaths = [ + builder.getPath(home), + builder.getPath(dir), + builder.getPath(file1), + builder.getPath(file2) + ] + + var changes = [] + watcher = watch(fpaths, { delay: 100, recursive: true }, function(evt, name) { + changes.push(name) + }) + + watcher.on('ready', function() { + builder.modify(file1) + builder.modify(file2, 50) + + wait(function() { + assert.deepStrictEqual( + changes, + [builder.getPath(file1), builder.getPath(file2)] + ) + done() + }, 200) + }) + }) +}) + +t.describe('watcher object', function() { + t.test('should using watcher object to watch', function(done) { + var dir = builder.getPath('home/a') + var file = 'home/a/file1' + var fpath = builder.getPath(file) + + watcher = watch(dir, { delay: 0 }) + watcher.on('ready', function() { + watcher.on('change', function(evt, name) { + assert.strictEqual(evt, 'update') + assert.strictEqual(name, fpath) + done() + }) + builder.modify(file) + }) + }) + + t.describe('close()', function() { + t.test('should close a watcher using .close()', function(done) { + var dir = builder.getPath('home/a') + var file = 'home/a/file1' + var times = 0 + watcher = watch(dir, { delay: 0 }) + watcher.on('change', function(evt, name) { + times++ + }) + watcher.on('ready', function() { + + watcher.close() + + builder.modify(file) + builder.modify(file, 100) + + wait(function() { + assert(watcher.isClosed(), 'watcher should be closed') + assert.strictEqual(times, 0, 'failed to close the watcher') + done() + }, 150) + }) + }) + + t.test('should not watch after .close() is called', function(done) { + var dir = builder.getPath('home') + watcher = watch(dir, { delay: 0, recursive: true }) + watcher.close() + + watcher.getWatchedPaths(function(dirs) { + assert(dirs.length === 0) + done() + }) + }) + + t.test('Do not emit after close', function(done) { + var dir = builder.getPath('home/a') + var file = 'home/a/file1' + var times = 0 + watcher = watch(dir, { delay: 0 }) + watcher.on('change', function(evt, name) { + times++ + }) + watcher.on('ready', function() { + + watcher.close() + + var timer = setInterval(function() { + builder.modify(file) + }) + + wait(function() { + clearInterval(timer) + assert(watcher.isClosed(), 'watcher should be closed') + assert.strictEqual(times, 0, 'failed to close the watcher') + done() + }, 100) + }) + }) + + }) + + t.describe('getWatchedPaths()', function() { + t.test('should get all the watched paths', function(done) { + var home = builder.getPath('home') + watcher = watch(home, { + delay: 0, + recursive: true + }) + watcher.getWatchedPaths(function(paths) { + hasNativeRecursive(function(supportRecursive) { + var watched = supportRecursive + // The home directory is the only one that's being watched + // if the recursive option is natively supported. + ? [home] + // Otherwise it should include all its subdirectories. + : builder.getAllDirectories() + + assert.deepStrictEqual( + watched.sort(), paths.sort() + ) + + done() + }) + }) + }) + + t.test('should get its parent path instead of the file itself', function(done) { + var file = builder.getPath('home/a/file1') + // The parent path is actually being watched instead. + var parent = builder.getPath('home/a') + + watcher = watch(file, { delay: 0 }) + + watcher.getWatchedPaths(function(paths) { + assert.deepStrictEqual([parent], paths) + done() + }) + }) + + t.test('should work correctly with composed watcher', function(done) { + var a = builder.getPath('home/a') + + var b = builder.getPath('home/b') + var file = builder.getPath('home/b/file1') + + var nested = builder.getPath('home/deep_node_modules') + var ma = builder.getPath('home/deep_node_modules/ma') + var mb = builder.getPath('home/deep_node_modules/mb') + var mc = builder.getPath('home/deep_node_modules/mc') + + watcher = watch([a, file, nested], { + delay: 0, + recursive: true + }) + + watcher.getWatchedPaths(function(paths) { + hasNativeRecursive(function(supportRecursive) { + var watched = supportRecursive + ? [a, b, nested] + : [a, b, nested, ma, mb, mc] + + assert.deepStrictEqual( + watched.sort(), paths.sort() + ) + + done() + }) + }) + }) + }) +}) diff --git a/test/watch/builder.mjs b/test/watch/builder.mjs new file mode 100644 index 0000000..af4fcea --- /dev/null +++ b/test/watch/builder.mjs @@ -0,0 +1,234 @@ +import fs from 'fs/promises' +import fsSync from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +let structure = ` +home/ + a/ + file1 + file2 + b/ + file1 + file2 + c/ + bb/ + file1 + file2 + d/ + file1 + file2 + e/ + file1 + file2 + sub/ + deep_node_modules/ + ma/ + file1 + file2 + mb/ + file1 + file2 + mc/ +` + +let code = structure + .split('\n') + .filter(line => line) + .map(function(line) { + return { + indent: line.length - line.replace(/^\s+/,'').length, + type: /\/$/.test(line) ? 'dir': 'file', + text: line.replace(/^\s+|\s*\/\s*|\s+$/g, '') + } + }) + +function join(arr) { + return arr.join('/') +} + +function transform(arr) { + let result = [] + let temp = [] + let indent = 0 + arr.forEach(function(line) { + if (!line.text) { + return + } + else if (!line.indent) { + temp.push(line.text) + result.push({type: line.type, text: join(temp) }) + } + else if (indent < line.indent) { + temp.push(line.text) + result[result.length - 1].type = 'dir' + result.push({type: line.type, text: join(temp) }) + } + else if (indent === line.indent) { + temp.pop() + temp.push(line.text) + result.push({type: line.type, text: join(temp) }) + } + else if(indent > line.indent) { + temp.pop() + temp.pop() + temp.push(line.text) + result.push({type: line.type, text: join(temp) }) + } + + indent = line.indent + }) + return result +} + +function gracefully(promise) { + return promise.catch(function(err) { + console.log(err) + }) +} + +function emptyFile(target) { + let folder = target.slice(0, target.lastIndexOf('/')) + + /*return fs.mkdir(folder, { recursive: true, force: true }) + .then(() => fs.open(target, 'w').then(fd => fd.close()))*/ + + return fs.stat(folder) + .catch(() => fs.mkdir(folder, { recursive: true, force: true })) + .then(() => { + return fs.open(target, 'w').then(fd => fd.close()) + }) +} + +export function Builder() { + this.root = path.join(__dirname, '__TREE__') + this.transformed = transform(code) +} + +Builder.prototype.init = async function() { + await Promise.all( + this.transformed.filter(line => line.type === 'dir').map(line => { + let target = path.join(this.root, line.text) + return gracefully(fs.mkdir(target, { recursive: true })) + }) + ) + await Promise.all( + this.transformed.filter(line => line.type !== 'dir').map(line => { + let target = path.join(this.root, line.text) + return gracefully(emptyFile(target)) + }) + ) +} + +Builder.prototype.getPath = function(fpath, sub) { + return path.join(this.root, fpath, sub || '') +} + +Builder.prototype.modify = function(fpath, delay) { + if (delay) throw new Error('remove delay') + let filePath = this.getPath(fpath) + return fs.appendFile(filePath, 'hello') +} + +Builder.prototype.remove = function(fpath) { + let filePath = this.getPath(fpath) + return fs.rm(filePath, { recursive: true, force: true }) +} + +Builder.prototype.removeSync = function(fpath) { + let filePath = this.getPath(fpath) + return fsSync.rmSync(filePath, { recursive: true, force: true }) +} + +Builder.prototype.newFile = function(fpath, delay) { + if (delay) throw new Error('remove delay') + let filePath = this.getPath(fpath) + return emptyFile(filePath) +} + +Builder.prototype.createDirectory = function(fpath) { + let filePath = this.getPath(fpath) + return fs.mkdir(filePath, { recursive: true }) +} + +Builder.prototype.randomName = function() { + return Math.random().toString(36).substring(2, 14) +} + +Builder.prototype.newRandomFiles = function(fpath, count) { + return Promise.all( + new Array(count).fill(0).map(() => { + let name = this.randomName() + let filePath = this.getPath(fpath, name) + + return emptyFile(filePath).then(() => name) + }) + ) +} + +Builder.prototype.cleanup = function() { + return fs.rmdir(this.root) +} + +Builder.prototype.getAllDirectories = function() { + function walk(dir) { + let ret = [] + fsSync.readdirSync(dir).forEach(function(d) { + let fpath = path.join(dir, d) + if (fsSync.statSync(fpath).isDirectory()) { + ret.push(fpath) + ret = ret.concat(walk(fpath)) + } + }) + return ret + } + return walk(this.root) +} + +export function Counter(res, countTotal, waitForSignal = false) { + this._res = res + this.counter = 0 + this._countTotal = countTotal + this._startCount = !waitForSignal + this._gotSignal = false + this._signal = null +} + +Counter.prototype.waitForCount = function(promise) { + if (!promise) { + promise = Promise.resolve() + } + return new Promise(res => { + promise.then(() => { + this._signal = res + this.hasSignal() + }) + }) +} + +Counter.prototype.hasSignal = function() { + if (this._gotSignal && this._signal) { + this._gotSignal = false + let temp = this._signal + this._signal = null + temp() + } +} + +Counter.prototype.startCounting = function() { + this._startCount = true +} + +Counter.prototype.count = function() { + if (!this._startCount) return + + this._gotSignal = true + this.counter++ + if (this.counter === this._countTotal && this._res) { + this._res() + } + + this.hasSignal() +}