Repository: fgnass/node-dev
Pull request #224 (2019-05-02 03:32:13)

Update eslint
- Update devDependencies
  - `eslint` from `v2.0.0` to `v5.16.0`
  - `eslint-config-airbnb-base` from `v3.0.1` to `v13.1.0`
  - `eslint-plugin-import` from `v1.8.1` to `v2.17.2`
- IPC does not require windows and is now tested
- Avoid using underscores in filenames
- We only skip `SIGTERM` test on windows
- Path building now uses `path.join` instead of raw string concatenation


bjornstar: So a little over a year later and I got the tests passing.

There was a lot of painful debugging to do in the tests, but I think I got most of the race conditions ironed out.

In the end I updated the following devDependencies:

`eslint` from `2.0.0` to `7.3.1`
`eslint-config-airbnb-base` from `3.0.1` to `14.2.0`
`eslint-plugin-import` from `1.8.1` to `2.22.0`
`tap` from `12.6.2` to `14.10.7`
`touch` from `1.0.0` to `3.1.0`

Very little has changed, I think a minor revision bump is in order.

Patch:
diff --git a/.eslintrc b/.eslintrc
index 07fa2b8..a9dfa34 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -14,6 +14,7 @@
     "vars-on-top": 0,
     "no-console": 0,
     "no-param-reassign": 0,
-    "no-use-before-define": 0
+    "no-use-before-define": 0,
+    "strict": 0
   }
 }
diff --git a/.gitignore b/.gitignore
index 9b8f00b..24114f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 .DS_Store
+.nyc_output
 .vagrant
 node_modules
 npm-debug.log
diff --git a/.npmignore b/.npmignore
index 4cb5bb4..8bc3236 100644
--- a/.npmignore
+++ b/.npmignore
@@ -1,2 +1,3 @@
+.nyc_output
 package-lock.json
 test
diff --git a/.travis.yml b/.travis.yml
index e249bcc..c2986a6 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,8 +4,7 @@ jobs:
       script: npm run lint
 language: node_js
 node_js:
+  - 14
   - 12
   - 10
-  - 8
-  - 6
 sudo: false
diff --git a/lib/cfg.js b/lib/cfg.js
index 4cb7580..99b6fa8 100644
--- a/lib/cfg.js
+++ b/lib/cfg.js
@@ -42,7 +42,6 @@ module.exports = function (main, opts) {
     clear: !!c.clear,
     dedupe: !!c.dedupe,
     graceful_ipc: c.graceful_ipc,
-    ms_win: process.platform === 'win32',
     ignore: ignore,
     respawn: c.respawn || false,
     extensions: c.extensions || {
diff --git a/lib/index.js b/lib/index.js
index 66f1e69..7f864ac 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,5 +1,6 @@
 var fork = require('child_process').fork;
 var filewatcher = require('filewatcher');
+var path = require('path');
 var ipc = require('./ipc');
 var resolveMain = require('./resolveMain');
 
@@ -19,14 +20,14 @@ module.exports = function (script, scriptArgs, nodeArgs, opts) {
   // The child_process
   var child;
 
-  var wrapper = resolveMain(__dirname + '/wrap.js');
+  var wrapper = resolveMain(path.join(__dirname, 'wrap.js'));
   var main = resolveMain(script);
   var cfg = require('./cfg')(main, opts);
   var log = require('./log')(cfg);
   var notify = require('./notify')(cfg, log);
 
   // Run ./dedupe.js as preload script
-  if (cfg.dedupe) process.env.NODE_DEV_PRELOAD = __dirname + '/dedupe';
+  if (cfg.dedupe) process.env.NODE_DEV_PRELOAD = path.join(__dirname, 'dedupe');
 
   var watcher = filewatcher({ forcePolling: opts.poll });
 
@@ -89,8 +90,8 @@ module.exports = function (script, scriptArgs, nodeArgs, opts) {
   function stop(willTerminate) {
     child.respawn = true;
     if (!willTerminate) {
-      if (cfg.ms_win && cfg.graceful_ipc) {
-        log.info('Windows workaround, sending "' + cfg.graceful_ipc + '" message to child.');
+      if (cfg.graceful_ipc) {
+        log.info('Sending IPC: ' + JSON.stringify(cfg.graceful_ipc));
         child.send(cfg.graceful_ipc);
       } else {
         child.kill('SIGTERM');
@@ -101,9 +102,9 @@ module.exports = function (script, scriptArgs, nodeArgs, opts) {
 
   // Relay SIGTERM
   process.on('SIGTERM', function () {
-    if (child) {
-      if (cfg.ms_win && cfg.graceful_ipc) {
-        log.info('Windows workaround, sending "' + cfg.graceful_ipc + '" message to child.');
+    if (child && child.connected) {
+      if (cfg.graceful_ipc) {
+        log.info('Sending IPC: ' + JSON.stringify(cfg.graceful_ipc));
         child.send(cfg.graceful_ipc);
       } else {
         child.kill('SIGTERM');
@@ -133,7 +134,7 @@ function getLevel(mod) {
 function getPrefix(mod) {
   var n = 'node_modules';
   var i = mod.lastIndexOf(n);
-  return ~i ? mod.slice(0, i + n.length) : '';
+  return i !== -1 ? mod.slice(0, i + n.length) : '';
 }
 
 function isPrefixOf(value) {
diff --git a/lib/log.js b/lib/log.js
index fd503ae..8245c36 100644
--- a/lib/log.js
+++ b/lib/log.js
@@ -7,22 +7,26 @@ var colors = {
   warn: '33'
 };
 
+function noop(s) { return s; }
+
+function colorFactory(enableColor) {
+  return enableColor ? function (s, c) {
+    return '\x1B[' + c + 'm' + s + '\x1B[0m';
+  } : noop;
+}
+
 /**
  * Logs a message to the console. The level is displayed in ANSI colors,
  * either bright red in case of an error or green otherwise.
  */
 module.exports = function (cfg) {
-  function color(s, c) {
-    if (process.stdout.isTTY) {
-      return '\x1B[' + c + 'm' + s + '\x1B[0m';
-    }
-    return s;
-  }
+  var enableColor = !(cfg.noColor || !process.stdout.isTTY);
+  var color = colorFactory(enableColor);
 
   function log(msg, level) {
-    if (cfg.timestamp) msg = color(fmt(new Date(), cfg.timestamp), '39') + ' ' + msg;
+    var timestamp = cfg.timestamp ? color(fmt(new Date(), cfg.timestamp), '39') + ' ' : '';
     var c = colors[level.toLowerCase()] || '32';
-    var output = '[' + color(level.toUpperCase(), c) + '] ' + msg;
+    var output = '[' + color(level.toUpperCase(), c) + '] ' + timestamp + msg;
     console.log(output);
     return output;
   }
diff --git a/package.json b/package.json
index ee2c882..b250531 100644
--- a/package.json
+++ b/package.json
@@ -24,11 +24,11 @@
   "main": "./lib",
   "preferGlobal": true,
   "engines": {
-    "node": ">=6"
+    "node": ">=10"
   },
   "scripts": {
     "lint": "eslint lib test",
-    "test": "tap test/*.js"
+    "test": "node test"
   },
   "dependencies": {
     "dateformat": "^3.0.3",
@@ -40,10 +40,11 @@
   },
   "devDependencies": {
     "coffeescript": "^2.4.1",
-    "eslint": "^2.0.0",
-    "eslint-config-airbnb-base": "^3.0.1",
-    "eslint-plugin-import": "^1.8.1",
-    "tap": "^12.6.2",
-    "touch": "^1.0.0"
+    "eslint": "^7.3.1",
+    "eslint-config-airbnb-base": "^14.2.0",
+    "eslint-plugin-import": "^2.22.0",
+    "nyc": "^15.1.0",
+    "tap": "^14.10.7",
+    "touch": "^3.1.0"
   }
 }


Test patch:
diff --git a/test/fixture/catchNoSuchModule.js b/test/fixture/catchNoSuchModule.js
index f3c6c13..20d9b87 100644
--- a/test/fixture/catchNoSuchModule.js
+++ b/test/fixture/catchNoSuchModule.js
@@ -1,5 +1,4 @@
 try {
-  /* eslint-disable import/no-unresolved */
   require('some_module_that_does_not_exist');
 } catch (err) {
   console.log('Caught', err);
diff --git a/test/fixture/cluster.js b/test/fixture/cluster.js
index e2ee8a2..b0961d0 100644
--- a/test/fixture/cluster.js
+++ b/test/fixture/cluster.js
@@ -1,19 +1,41 @@
 var cluster = require('cluster');
 
-function createWorker() {
+function createWorker(i) {
   var worker = cluster.fork();
+
   worker.on('message', function (msg) {
-    console.log('Message from worker:', msg);
+    console.log('Message from worker' + i + ':', msg);
+  });
+
+  worker.on('exit', function (code) {
+    console.log('Worker', i, 'exited with code:', code);
+  });
+
+  return worker;
+}
+
+if (cluster.isWorker) {
+  var server = require('./server');
+
+  process.on('disconnect', function () {
+    console.log(process.pid, 'disconnect received, shutting down');
+    if (server.lisening) {
+      server.close();
+    }
   });
+
+  process.send('Hello');
 }
 
 if (cluster.isMaster) {
-  for (var i = 0; i < 2; i++) {
+  for (var i = 0; i < 2; i += 1) {
     console.log('Forking worker', i);
-    createWorker();
+    createWorker(i);
   }
-} else {
-  console.log('Worker started.');
-  process.send('Hello');
-  require('./server');
+  process.once('SIGTERM', function () {
+    console.log('Master received SIGTERM');
+    cluster.disconnect(function () {
+      console.log('All workers disconnected.');
+    });
+  });
 }
diff --git a/test/fixture/gc.js b/test/fixture/gc.js
index 96b97af..57947dd 100644
--- a/test/fixture/gc.js
+++ b/test/fixture/gc.js
@@ -1,1 +1,1 @@
-console.log(process.argv[2], typeof(global.gc));
+console.log(process.argv[2], typeof global.gc);
diff --git a/test/fixture/win_server.js b/test/fixture/ipc-server.js
similarity index 76%
rename from test/fixture/win_server.js
rename to test/fixture/ipc-server.js
index f9d7458..4a9607f 100644
--- a/test/fixture/win_server.js
+++ b/test/fixture/ipc-server.js
@@ -1,4 +1,3 @@
-
 var http = require('http');
 var message = require('./message');
 
@@ -12,12 +11,11 @@ server.on('listening', function () {
   var addr = this.address();
   console.log('Server listening on %s:%s', addr.address, addr.port);
   console.log(message);
-})
-.listen(0);
+}).listen(0);
 
 process.on('message', function (data) {
-  if (data === 'node-dev_restart') {
-    console.log('win_server.js - win restart IPC received');
+  if (data === 'node-dev:restart') {
+    console.log('ipc-server.js - IPC received');
     server.close();
   }
 });
@@ -25,3 +23,7 @@ process.on('message', function (data) {
 process.on('exit', function () {
   console.log('exit');
 });
+
+process.on('SIGTERM', function () {
+  server.close();
+});
diff --git a/test/fixture/noSuchModule.js b/test/fixture/noSuchModule.js
index a2811b7..1b9c527 100644
--- a/test/fixture/noSuchModule.js
+++ b/test/fixture/noSuchModule.js
@@ -1,2 +1,1 @@
-/* eslint-disable import/no-unresolved */
 require('some_module_that_does_not_exist');
diff --git a/test/fixture/pid.js b/test/fixture/pid.js
index c60e17a..b609b4c 100644
--- a/test/fixture/pid.js
+++ b/test/fixture/pid.js
@@ -1,2 +1,3 @@
 require('http').createServer().listen(0);
 console.log(process.pid);
+process.on('SIGTERM', function () { process.exit(); });
diff --git a/test/fixture/server.coffee b/test/fixture/server.coffee
index acd4ab3..c2729cc 100644
--- a/test/fixture/server.coffee
+++ b/test/fixture/server.coffee
@@ -8,7 +8,11 @@ server = http.createServer (req, res) ->
     res.write message
     res.end '\n'
 
-server.listen 8080
+server.once 'listening', ->
+  addr = this.address()
+  console.log 'Server listening on %s:%s', addr.address, addr.port
+  console.log message
+.listen 0
 
-console.log 'Server running at http://localhost:8080/'
-console.log message
+process.once 'SIGTERM', -> server.close()
+process.once 'exit', -> console.log 'exit'
diff --git a/test/fixture/server.js b/test/fixture/server.js
index 75c737d..29acf39 100644
--- a/test/fixture/server.js
+++ b/test/fixture/server.js
@@ -1,10 +1,8 @@
-
 var http = require('http');
 var message = require('./message');
 
 // Changes to this module should not cause a server restart:
-/* eslint-disable no-unused-vars */
-var ignoredModule = require('./ignoredModule');
+require('./ignoredModule');
 
 var server = http.createServer(function (req, res) {
   res.writeHead(200, { 'Content-Type': 'text/plain' });
@@ -12,20 +10,20 @@ var server = http.createServer(function (req, res) {
   res.end('\n');
 });
 
-server.on('listening', function () {
+server.once('listening', function () {
   var addr = this.address();
   console.log('Server listening on %s:%s', addr.address, addr.port);
   console.log(message);
-})
-.listen(0);
+}).listen(0);
 
-process.on('SIGTERM', function () {
-  server.close();
-  setTimeout(function () { console.log('3'); }, 200);
-  setTimeout(function () { console.log('2'); }, 400);
-  setTimeout(function () { console.log('1'); }, 600);
+process.once('SIGTERM', function () {
+  if (server.listening) {
+    server.close();
+  }
 });
 
-process.on('exit', function () {
+process.once('beforeExit', function () {
   console.log('exit');
 });
+
+module.exports = server;
diff --git a/test/fixture/vmtest.js b/test/fixture/vmtest.js
index 2597402..0510706 100644
--- a/test/fixture/vmtest.js
+++ b/test/fixture/vmtest.js
@@ -1,6 +1,7 @@
-var vm = require('vm');
 var fs = require('fs');
-var file = __dirname + '/log.js';
+var path = require('path');
+var vm = require('vm');
+var file = path.join(__dirname, 'log.js');
 var str = fs.readFileSync(file, 'utf8');
 
 if (process.argv.length > 2 && process.argv[2] === 'nofile') {
@@ -9,5 +10,15 @@ if (process.argv.length > 2 && process.argv[2] === 'nofile') {
   vm.runInNewContext(str, { module: {}, require: require, console: console }, file);
 }
 
+function noop() {}
+
 // Listen for events to keep running
-process.on('message', function () {});
+process.on('message', noop);
+
+process.on('SIGTERM', function () {
+  process.removeListener('message', noop);
+});
+
+process.on('exit', function () {
+  console.log('exit');
+});
diff --git a/test/index.js b/test/index.js
index 9aa1051..d6ad1d1 100644
--- a/test/index.js
+++ b/test/index.js
@@ -1,260 +1,3 @@
-var child = require('child_process');
-var tap = require('tap');
-var touch = require('touch');
-
-var dir = __dirname + '/fixture';
-var bin = __dirname + '/../bin/node-dev';
-var msgFile = dir + '/message.js';
-
-// Helpers
-function touchFile(file) {
-  return function () {
-    touch.sync(file || msgFile);
-  };
-}
-
-function spawn(cmd, cb) {
-  var ps = child.spawn('node', [bin].concat(cmd.split(' ')), { cwd: dir });
-  var out = '';
-  var err = '';
-
-  if (cb) {
-
-    // capture stderr
-    ps.stderr.on('data', function (data) {
-      err += data.toString();
-    });
-
-    // invoke callback
-    ps.on('exit', function (code, signal) {
-      if (err) cb(err, code, signal);
-    });
-
-    ps.stdout.on('data', function (data) {
-      out += data.toString();
-      var ret = cb.call(ps, out);
-      if (typeof ret == 'function') {
-        // use the returned function as new callback
-        cb = ret;
-      } else if (ret && ret.exit) {
-        // kill the process and invoke the given function
-        ps.stdout.removeAllListeners('data');
-        ps.stderr.removeAllListeners('data');
-        ps.removeAllListeners('exit');
-        ps.on('exit', function () { setTimeout(ret.exit, 1000); });
-        ps.kill();
-      }
-    });
-  }
-
-  return ps;
-}
-
-function run(cmd, done) {
-  spawn(cmd, function (out) {
-    if (out.match(/touch message.js/)) {
-      setTimeout(touchFile(), 500);
-      return function (out2) {
-        if (out2.match(/Restarting/)) {
-          return { exit: done };
-        }
-      };
-    }
-  });
-}
-
-// Tests
-
-tap.test('should pass unknown args to node binary', function (t) {
-  spawn('--expose_gc gc.js foo', function (out) {
-    t.is(out.trim(), 'foo function');
-    return { exit: t.end.bind(t) };
-  });
-});
-
-tap.test('should restart the server', function (t) {
-  run('server.js', t.end.bind(t));
-});
-
-tap.test('should restart the server twice', function (t) {
-  spawn('server.js', function (out) {
-    if (out.match(/touch message.js/)) {
-      setTimeout(touchFile(), 500);
-      return function (out2) {
-        if (out2.match(/Restarting/)) {
-          setTimeout(touchFile(), 500);
-          return function (out3) {
-            if (out3.match(/Restarting/)) {
-              return { exit: t.end.bind(t) };
-            }
-          };
-        }
-      };
-    }
-  });
-});
-
-tap.test('should restart the cluster', function (t) {
-  run('cluster.js', t.end.bind(t));
-});
-
-tap.test('should support vm functions', function (t) {
-  run('vmtest.js', t.end.bind(t));
-});
-
-tap.test('should support vm functions with missing file argument', function (t) {
-  run('vmtest.js nofile', t.end.bind(t));
-});
-
-tap.test('should support coffeescript', function (t) {
-  run('server.coffee', t.end.bind(t));
-});
-
-tap.test('should handle errors', function (t) {
-  spawn('error.js', function (out) {
-    if (out.match(/ERROR/)) {
-      setTimeout(touchFile(), 500);
-      return function (out2) {
-        if (out2.match(/Restarting/)) {
-          return { exit: t.end.bind(t) };
-        }
-      };
-    }
-  });
-});
-
-tap.test('should watch if no such module', function (t) {
-  spawn('noSuchModule.js', function (out) {
-    t.like(out, /ERROR/);
-    return { exit: t.end.bind(t) };
-  });
-});
-
-tap.test('should run async code un uncaughtException handlers', function (t) {
-  spawn('uncaughtExceptionHandler.js', function (out) {
-    if (out.match(/ERROR/)) {
-      return function (out2) {
-        if (out2.match(/async \[?ReferenceError/)) {
-          return { exit: t.end.bind(t) };
-        }
-      };
-    }
-  });
-});
-
-tap.test('should ignore caught errors', function (t) {
-  spawn('catchNoSuchModule.js', function (out) {
-    t.like(out, /Caught/);
-    return { exit: t.end.bind(t) };
-  });
-});
-
-tap.test('should not show up in argv', function (t) {
-  spawn('argv.js foo', function (out) {
-    var argv = JSON.parse(out.replace(/'/g, '"'));
-    t.like(argv[0], /.*?node(js|\.exe)?$/);
-    t.is(argv[1], 'argv.js');
-    t.is(argv[2], 'foo');
-    return { exit: t.end.bind(t) };
-  });
-});
-
-tap.test('should pass through the exit code', function (t) {
-  spawn('exit.js').on('exit', function (code) {
-    t.is(code, 101);
-    t.end();
-  });
-});
-
-tap.test('should conceal the wrapper', function (t) {
-  // require.main should be main.js not wrap.js!
-  spawn('main.js').on('exit', function (code) {
-    t.is(code, 0);
-    t.end();
-  });
-});
-
-tap.test('should relay stdin', function (t) {
-  spawn('echo.js', function (out) {
-    t.is(out, 'foo');
-    return { exit: t.end.bind(t) };
-  }).stdin.write('foo');
-});
-
-tap.test('should kill the forked processes', function (t) {
-  spawn('pid.js', function (out) {
-    var pid = parseInt(out, 10);
-    return { exit: function () {
-      setTimeout(function () {
-        try {
-          process.kill(pid);
-          t.fail('child must no longer run');
-        } catch (e) {
-          t.end();
-        }
-      }, 500);
-    } };
-  });
-});
-
-tap.test('should set NODE_ENV', function (t) {
-  spawn('env.js', function (out) {
-    t.like(out, /development/);
-    return { exit: t.end.bind(t) };
-  });
-});
-
-tap.test('should allow graceful shutdowns', function (t) {
-  if (process.platform === 'win32') {
-    t.pass('should allow graceful shutdowns', { skip: 'Signals are not supported on Windows' });
-    t.end();
-  } else {
-    spawn('server.js', function (out) {
-      if (out.match(/touch message.js/)) {
-        setTimeout(touchFile(), 500);
-        return function (out2) {
-          if (out2.match(/exit/)) {
-            return { exit: t.end.bind(t) };
-          }
-        };
-      }
-    });
-  }
-});
-
-tap.test('should send IPC shutdown on Windows', function (t) {
-  if (process.platform !== 'win32') {
-    t.pass('should send IPC shutdown on Windows', { skip: 'IPC not needed on non-Windows' });
-    t.end();
-  } else {
-    spawn('--graceful_ipc=node-dev_restart win_server.js', function (out) {
-      if (out.match(/touch message.js/)) {
-        setTimeout(touchFile(), 500);
-        return function (out2) {
-          if (out2.match(/win restart IPC received/)) {
-            return { exit: t.end.bind(t) };
-          }
-        };
-      }
-    });
-  }
-});
-
-tap.test('should be resistant to breaking `require.extensions`', function (t) {
-  spawn('modify-extensions.js', function (out) {
-    t.notOk(/TypeError/.test(out));
-  });
-  setTimeout(t.end.bind(t), 500);
-});
-
-tap.test('Logs timestamp by default', function (t) {
-  spawn('server.js', function (out) {
-    if (out.match(/touch message.js/)) {
-      setTimeout(touchFile(), 500);
-      return function (out2) {
-        t.like(out2, /\[INFO\] \d{2}:\d{2}:\d{2} Restarting/);
-        return { exit: t.end.bind(t) };
-      };
-    }
-  });
-});
+require('./log.js');
+require('./run.js');
+require('./spawn.js');
diff --git a/test/log.js b/test/log.js
index bc9c22e..f96c324 100644
--- a/test/log.js
+++ b/test/log.js
@@ -1,6 +1,9 @@
 var tap = require('tap');
 
 var cfg = require('../lib/cfg.js')();
+
+cfg.noColor = true;
+
 var logFactory = require('../lib/log.js');
 var log = logFactory(cfg);
 
diff --git a/test/run.js b/test/run.js
new file mode 100644
index 0000000..5732e1f
--- /dev/null
+++ b/test/run.js
@@ -0,0 +1,23 @@
+var tap = require('tap');
+
+var run = require('./utils/run');
+
+tap.test('should restart the server', function (t) {
+  run('server.js', t.end.bind(t));
+});
+
+tap.test('should restart the cluster', function (t) {
+  run('cluster.js', t.end.bind(t));
+});
+
+tap.test('should support vm functions', function (t) {
+  run('vmtest.js', t.end.bind(t));
+});
+
+tap.test('should support vm functions with missing file argument', function (t) {
+  run('vmtest.js nofile', t.end.bind(t));
+});
+
+tap.test('should support coffeescript', function (t) {
+  run('server.coffee', t.end.bind(t));
+});
diff --git a/test/spawn.js b/test/spawn.js
new file mode 100644
index 0000000..600b51a
--- /dev/null
+++ b/test/spawn.js
@@ -0,0 +1,177 @@
+var tap = require('tap');
+var spawn = require('./utils/spawn');
+var touchFile = require('./utils/touch-file');
+
+tap.test('should pass unknown args to node binary', function (t) {
+  spawn('--expose_gc gc.js foo', function (out) {
+    t.is(out.trim(), 'foo function');
+    return { exit: t.end.bind(t) };
+  });
+});
+
+tap.test('should restart the server twice', function (t) {
+  spawn('server.js', function (out) {
+    if (out.match(/touch message.js/)) {
+      touchFile();
+      return function (out2) {
+        if (out2.match(/Restarting/)) {
+          touchFile();
+          return function (out3) {
+            if (out3.match(/Restarting/)) {
+              return { exit: t.end.bind(t) };
+            }
+          };
+        }
+      };
+    }
+  });
+});
+
+tap.test('should handle errors', function (t) {
+  spawn('error.js', function (out) {
+    if (out.match(/ERROR/)) {
+      touchFile();
+      return function (out2) {
+        if (out2.match(/Restarting/)) {
+          return { exit: t.end.bind(t) };
+        }
+      };
+    }
+  });
+});
+
+tap.test('should watch if no such module', function (t) {
+  spawn('noSuchModule.js', function (out) {
+    t.like(out, /ERROR/);
+    return { exit: t.end.bind(t) };
+  });
+});
+
+tap.test('should run async code un uncaughtException handlers', function (t) {
+  spawn('uncaughtExceptionHandler.js', function (out) {
+    if (out.match(/ERROR/)) {
+      return function (out2) {
+        if (out2.match(/async \[?ReferenceError/)) {
+          return { exit: t.end.bind(t) };
+        }
+      };
+    }
+  });
+});
+
+tap.test('should ignore caught errors', function (t) {
+  spawn('catchNoSuchModule.js', function (out) {
+    t.like(out, /Caught/);
+    return { exit: t.end.bind(t) };
+  });
+});
+
+tap.test('should not show up in argv', function (t) {
+  spawn('argv.js foo', function (out) {
+    var argv = JSON.parse(out.replace(/'/g, '"'));
+    t.like(argv[0], /.*?node(js|\.exe)?$/);
+    t.is(argv[1], 'argv.js');
+    t.is(argv[2], 'foo');
+    return { exit: t.end.bind(t) };
+  });
+});
+
+tap.test('should pass through the exit code', function (t) {
+  spawn('exit.js').on('exit', function (code) {
+    t.is(code, 101);
+    t.end();
+  });
+});
+
+tap.test('should conceal the wrapper', function (t) {
+  // require.main should be main.js not wrap.js!
+  spawn('main.js').on('exit', function (code) {
+    t.is(code, 0);
+    t.end();
+  });
+});
+
+tap.test('should relay stdin', function (t) {
+  var p = spawn('echo.js', function (out) {
+    t.is(out, 'foo');
+    return { exit: t.end.bind(t) };
+  });
+  p.stdin.write('foo');
+});
+
+tap.test('should kill the forked processes', function (t) {
+  spawn('pid.js', function (out) {
+    var pid = parseInt(out, 10);
+    return {
+      exit: function () {
+        setTimeout(function () {
+          try {
+            process.kill(pid);
+            t.fail('child must no longer run');
+          } catch (e) {
+            t.end();
+          }
+        }, 500);
+      }
+    };
+  });
+});
+
+tap.test('should set NODE_ENV', function (t) {
+  spawn('env.js', function (out) {
+    t.like(out, /development/);
+    return { exit: t.end.bind(t) };
+  });
+});
+
+tap.test('should allow graceful shutdowns', function (t) {
+  if (process.platform === 'win32') {
+    t.pass('should allow graceful shutdowns', { skip: 'Signals are not supported on Windows' });
+    t.end();
+  } else {
+    spawn('server.js', function (out) {
+      if (out.match(/touch message.js/)) {
+        touchFile();
+        return function (out2) {
+          if (out2.match(/exit/)) {
+            return { exit: t.end.bind(t) };
+          }
+        };
+      }
+    });
+  }
+});
+
+tap.test('should send IPC message when configured', function (t) {
+  spawn('--graceful_ipc=node-dev:restart ipc-server.js', function (out) {
+    if (out.match(/touch message.js/)) {
+      touchFile();
+      return function (out2) {
+        if (out2.match(/IPC received/)) {
+          return { exit: t.end.bind(t) };
+        }
+      };
+    }
+  });
+});
+
+tap.test('should be resistant to breaking `require.extensions`', function (t) {
+  spawn('modify-extensions.js', function (out) {
+    t.notOk(/TypeError/.test(out));
+  });
+  setTimeout(t.end.bind(t), 0);
+});
+
+tap.test('Logs timestamp by default', function (t) {
+  spawn('server.js', function (out) {
+    if (out.match(/touch message.js/)) {
+      touchFile();
+      return function (out2) {
+        if (out2.match(/Restarting/)) {
+          t.like(out2, /\[INFO\] \d{2}:\d{2}:\d{2} Restarting/);
+          return { exit: t.end.bind(t) };
+        }
+      };
+    }
+  });
+});
diff --git a/test/utils/run.js b/test/utils/run.js
new file mode 100644
index 0000000..f75a8ea
--- /dev/null
+++ b/test/utils/run.js
@@ -0,0 +1,21 @@
+var spawn = require('./spawn');
+var touchFile = require('./touch-file');
+
+function run(cmd, done) {
+  return spawn(cmd, function (out) {
+    console.log(cmd, 1, out);
+    var touched = false;
+    if (!touched && out.match(/touch message.js/)) {
+      touchFile();
+      touched = true;
+      return function (out2) {
+        console.log(cmd, 2, out2);
+        if (out2.match(/Restarting/)) {
+          return { exit: done };
+        }
+      };
+    }
+  });
+}
+
+module.exports = run;
diff --git a/test/utils/spawn.js b/test/utils/spawn.js
new file mode 100644
index 0000000..ea24ec1
--- /dev/null
+++ b/test/utils/spawn.js
@@ -0,0 +1,45 @@
+var child = require('child_process');
+var path = require('path');
+
+var bin = path.join(__dirname, '..', '..', 'bin', 'node-dev');
+var dir = path.join(__dirname, '..', 'fixture');
+
+function spawn(cmd, cb) {
+  var ps = child.spawn('node', [bin].concat(cmd.split(' ')), { cwd: dir });
+  var err = '';
+
+  function errorHandler(data) {
+    console.log(data.toString());
+    err += data.toString();
+  }
+
+  function exitHandler(code, signal) {
+    if (err) cb(err, code, signal);
+  }
+
+  function outHandler(data) {
+    var ret = cb.call(ps, data.toString());
+
+    if (typeof ret == 'function') {
+      // use the returned function as new callback
+      cb = ret;
+    } else if (ret && ret.exit) {
+      // kill the process and invoke the given function
+      ps.removeListener('exit', exitHandler);
+      ps.once('exit', function (code) {
+        ps.stdout.removeListener('data', outHandler);
+        ps.stderr.removeListener('data', errorHandler);
+        ret.exit(code);
+      });
+      ps.kill('SIGTERM');
+    }
+  }
+
+  ps.stderr.on('data', errorHandler);
+  ps.once('exit', exitHandler);
+  ps.stdout.on('data', outHandler);
+
+  return ps;
+}
+
+module.exports = spawn;
diff --git a/test/utils/touch-file.js b/test/utils/touch-file.js
new file mode 100644
index 0000000..704e0b4
--- /dev/null
+++ b/test/utils/touch-file.js
@@ -0,0 +1,17 @@
+var path = require('path');
+var touch = require('touch');
+
+var dir = path.join(__dirname, '..', 'fixture');
+var msgFile = path.join(dir, 'message.js');
+
+// Helpers
+function touchFile() {
+  setTimeout(function () {
+    touch(msgFile);
+  // filewatcher requires a new mtime to trigger a change event
+  // but most file systems only have second precision, so wait
+  // one full second before touching.
+  }, 1000);
+}
+
+module.exports = touchFile;