UNPKG

@aoberoi/chokidar-cli

Version:

Ultra-fast cross-platform command line utility to watch file system changes.

256 lines (220 loc) 10.8 kB
const { unlinkSync, writeFileSync, readFileSync, existsSync } = require('fs'); const { resolve: pathResolve, join: pathJoin } = require('path'); const assert = require('assert'); const spawn = require('npm-run-all/lib/spawn');; /* * Paths used in tests (each of them are absolute) */ const testDir = pathResolve(__dirname); const packageDir = pathJoin(testDir, '..'); // File which is created on watched file changes, whose existence is used to verify if commands are run. const changeFile = pathJoin(testDir, 'dir/change'); const lessFile = pathJoin(testDir, 'dir/subdir/c.less'); const jsFile = pathJoin(testDir, 'dir/a.js'); /* * Timeouts used throughout the tests (each of them in milliseconds) */ const TIMEOUT_WATCH_READY = 1000; const TIMEOUT_CHANGE_DETECTED = 700; const TIMEOUT_PADDING = 300; const isWin = process.platform === 'win32'; const touch = isWin ? `copy NUL ${changeFile}` : `touch ${changeFile}`; describe('chokidar-cli', function () { describe('informational subcommands', function () { // Giving the informational subcommands a shorter timeout than the default since they should finish // relatively quickly (this cannot be done in a beforeEach hook because the timeout would apply to the hook). const timeToRun = 1000; this.timeout(timeToRun + TIMEOUT_PADDING); it('help should be successful', function () { return run('node index.js --help', timeToRun); }); it('version should be successful', function () { return run('node index.js -v', timeToRun); }); }); // TODO: When a failure happens by an assert throwing (not calling done), the child process will outlive the test // case, and potentially cause havoc in future test cases. Asserting inside setTimeout()s are probably a bad idea. describe('subcommands that use the file system', function () { it('**/*.less should detect all less files in dir tree', function (done) { const timeToRun = TIMEOUT_WATCH_READY + TIMEOUT_CHANGE_DETECTED + 100; this.timeout(timeToRun + TIMEOUT_PADDING); // No quotes needed in glob pattern because node process spawn does no globbing expectKilledByTimeout(hideWindowsENOTENT(run(`node index.js "test/dir/**/*.less" -c "${touch}"`, timeToRun))) .then(done, done); setTimeout(function afterWatchIsReady() { writeFileSync(lessFile, 'content'); setTimeout(function() { assert(existsSync(changeFile), 'change file should exist'); }, TIMEOUT_CHANGE_DETECTED); }, TIMEOUT_WATCH_READY); }); it('should throttle invocations of command', function (done) { // when two writes to a watched file happen within the throttleTime period, only the first one triggers // running the command const timeToRun = TIMEOUT_WATCH_READY + (2 * TIMEOUT_CHANGE_DETECTED) + 100; this.timeout(timeToRun + TIMEOUT_PADDING); const throttleTime = (2 * TIMEOUT_CHANGE_DETECTED) + 100; expectKilledByTimeout(hideWindowsENOTENT(run( `node index.js "test/dir/**/*.less" --debounce 0 --throttle ${throttleTime} -c "${touch}"`, timeToRun ))) .then(done, done); setTimeout(function afterWatchIsReady() { writeFileSync(lessFile, 'content'); setTimeout(function() { assert(existsSync(changeFile), 'change file should exist after first change'); deleteChangeFileSync(); writeFileSync(lessFile, 'more content'); setTimeout(function() { assert.equal(existsSync(changeFile), false, 'change file should not exist after second change'); }, TIMEOUT_CHANGE_DETECTED); }, TIMEOUT_CHANGE_DETECTED); }, TIMEOUT_WATCH_READY); }); it('should debounce invocations of command', function (done) { // when two writes to a watched file happen within the debounceTime period, the command should be run // after the debounce time has elapsed (and not before it has elapsed). const debouncePadding = 1000; const debounceTime = (2 * TIMEOUT_CHANGE_DETECTED); const timeToRun = TIMEOUT_WATCH_READY + debounceTime + debouncePadding + 100; this.timeout(timeToRun + TIMEOUT_PADDING); expectKilledByTimeout(hideWindowsENOTENT( run(`node index.js "test/dir/**/*.less" --debounce ${debounceTime} -c "${touch}"`, timeToRun) )) .then(done, done); setTimeout(function afterWatchIsReady() { writeFileSync(lessFile, 'content'); setTimeout(function() { assert.equal(existsSync(changeFile), false, 'change file should not exist earlier than debounce time (first)'); writeFileSync(lessFile, 'more content'); setTimeout(function() { assert.equal(existsSync(changeFile), false, 'change file should not exist earlier than debounce time (second)'); }, TIMEOUT_CHANGE_DETECTED); }, TIMEOUT_CHANGE_DETECTED); setTimeout(function() { assert(existsSync(changeFile), 'change file should exist after debounce time'); }, debounceTime + debouncePadding); }, TIMEOUT_WATCH_READY); }); it('should replace {path} and {event} in command', function (done) { const timeToRun = TIMEOUT_WATCH_READY + TIMEOUT_CHANGE_DETECTED + 200; this.timeout(timeToRun + TIMEOUT_PADDING); const command = "echo '{event}:{path}' > " + changeFile; expectKilledByTimeout(hideWindowsENOTENT(run(`node index.js "test/dir/a.js" -c "${command}"`, timeToRun))) .then(done, done); setTimeout(function() { writeFileSync(jsFile, 'content'); setTimeout(function () { var res = readFileSync(changeFile).toString().trim(); if (isWin) { // making up for difference in behavior for echo assert.equal(res, '\'change:test/dir/a.js\'', 'need event/path detail'); } else { assert.equal(res, 'change:test/dir/a.js', 'need event/path detail'); } }, TIMEOUT_CHANGE_DETECTED); }, TIMEOUT_WATCH_READY); }); afterEach(function () { deleteChangeFileSync() // TODO: should we depend on every system this runs in to have git? return run('git checkout HEAD test/dir', 1000); }); }); }); /* * Test Helpers */ /** * Cleans up the change file by making sure its removed. */ function deleteChangeFileSync() { try { unlinkSync(changeFile); } catch (error) { // if the file doesn't exist, then its fine to swallow the ENOENT error, otherwise throw if (error.code !== 'ENOENT') { throw error; } } } /** Flag for when a process is killed by the following helper */ const REASON_TIMEOUT = Symbol('REASON_TIMEOUT'); /** * Run a command. Returns a promise that resolves or rejects when the process finishes. The promise resolves when the * process finishes normally, and rejects when the process finishes abnormally (like being killed after a timeout). * @param {string} cmd - the command to run * @param {number} killTimeout - number of milliseconds to wait for the command to exit before killing it and * its children processes, defaults to 0. * @param {boolean} options.shouldInheritStdio - when set to true, the stdio will be piped to this processes stdio * which is useful for debugging but may cause zombie processes to stick around, defaults to false. * @returns {Promise} */ function run(cmd, killTimeout, { shouldInheritStdio = false } = {}) { let child; try { child = spawn(cmd, { stdio: shouldInheritStdio ? 'inherit' : null, // the cross-spawn package in the implementation of this call will give us some nice behavior in Windows // with this option turned on, however it turns off nice behavior in *nix platforms, so we conditionally // set it here. // shell: isWin, shell: true, }); } catch (error) { return Promise.reject(error); } return new Promise((resolve, reject) => { function e(error) { child.removeListener('close', c); reject(error); } function c(exitCode, signal) { child.removeListener('error', e); if (exitCode === 0 && !signal) { return resolve(); } const error = new Error('child process terminated abnormally'); error.reason = child._killedFromTimeout ? REASON_TIMEOUT : (exitCode === null ? signal : exitCode); reject(error); } child.once('error', e); child.once('close', c); setTimeout(() => { child._killedFromTimeout = true; child.kill(); }, killTimeout); }); } /** * Shim Windows ENOENT handling. This is needed because of https://github.com/moxystudio/node-cross-spawn/issues/104. * @param {Promise} runPromise - input promise * @returns {Promise} a promise that can stand in place of the original */ function hideWindowsENOTENT(runPromise) { return runPromise.catch((error) => { if (isWin && error.code === 'ENOENT') { // Let's just treat this like a SIGKILL const fakeError = new Error('child process terminated abnormally'); fakeError.reason = REASON_TIMEOUT; throw fakeError; } throw error; }) } /** * Enforces that the input promise, which comes from the output of run() above, rejects because of a timeout and for * no other reason. * @param {Promise} runPromise - input promise * @returns {Promise} a promise that resolves when the input promise rejected because of a timeout, rejects otherwise */ function expectKilledByTimeout(runPromise) { return runPromise.then( () => { // process terminated normally, which is not what is expected in this test; throw new Error('process terminated too soon'); }, (error) => { // only swallow the error if the reason was a timeout if (!error.reason || error.reason !== REASON_TIMEOUT) { throw error; } } ); }