ExpressoInsanely fast TDD framework for node featuring code coverage reporting. | |
| bin/expresso |
+
+!/usr/bin/env node
+ |
+
+
+ * Expresso
+ * Copyright(c) TJ Holowaychuk <tj@vision-media.ca>
+ * (MIT Licensed)
+
+ |
+
+
+
+ Module dependencies.
+
+ |
+
+var assert = require('assert'),
+ childProcess = require('child_process'),
+ http = require('http'),
+ path = require('path'),
+ sys = require('sys'),
+ cwd = process.cwd(),
+ fs = require('fs'),
+ defer;
+ |
+
+
+
+ Expresso version.
+
+ |
+
+var version = '0.6.4';
+ |
+
+
+
+ Failure count.
+
+ |
+
+var failures = 0;
+ |
+
+
+
+ Number of tests executed.
+
+ |
+
+var testcount = 0;
+ |
+
+
+
+ Whitelist of tests to run.
+
+ |
+
+var only = [];
+ |
+
+
+
+ Boring output.
+
+ |
+
+var boring = false;
+ |
+
+
+
+ Growl notifications.
+
+ |
+
+var growl = false;
+ |
+
+
+
+ Server port.
+
+ |
+
+var port = 5555;
+ |
+
+
+
+ Watch mode.
+
+ |
+
+var watch = false;
+ |
+
+
+
+ Execute serially.
+
+ |
+
+var serial = false;
+ |
+
+
+
+ Default timeout.
+
+ |
+
+var timeout = 2000;
+ |
+
+
+
+ Usage documentation.
+
+ |
+
+var usage = ''
+ + '[bold]{Usage}: expresso [options] <file ...>'
+ + '\n'
+ + '\n[bold]{Options}:'
+ + '\n -w, --watch Watch for modifications and re-execute tests'
+ + '\n -g, --growl Enable growl notifications'
+ + '\n -c, --coverage Generate and report test coverage'
+ + '\n -t, --timeout MS Timeout in milliseconds, defaults to 2000'
+ + '\n -r, --require PATH Require the given module path'
+ + '\n -o, --only TESTS Execute only the comma sperated TESTS (can be set several times)'
+ + '\n -I, --include PATH Unshift the given path to require.paths'
+ + '\n -p, --port NUM Port number for test servers, starts at 5555'
+ + '\n -s, --serial Execute tests serially'
+ + '\n -b, --boring Suppress ansi-escape colors'
+ + '\n -v, --version Output version number'
+ + '\n -h, --help Display help information'
+ + '\n';
+
+
+
+var files = [],
+ args = process.argv.slice(2);
+
+while (args.length) {
+ var arg = args.shift();
+ switch (arg) {
+ case '-h':
+ case '--help':
+ print(usage + '\n');
+ process.exit(1);
+ break;
+ case '-v':
+ case '--version':
+ sys.puts(version);
+ process.exit(1);
+ break;
+ case '-i':
+ case '-I':
+ case '--include':
+ if (arg = args.shift()) {
+ require.paths.unshift(arg);
+ } else {
+ throw new Error('--include requires a path');
+ }
+ break;
+ case '-o':
+ case '--only':
+ if (arg = args.shift()) {
+ only = only.concat(arg.split(/ *, */));
+ } else {
+ throw new Error('--only requires comma-separated test names');
+ }
+ break;
+ case '-p':
+ case '--port':
+ if (arg = args.shift()) {
+ port = parseInt(arg, 10);
+ } else {
+ throw new Error('--port requires a number');
+ }
+ break;
+ case '-r':
+ case '--require':
+ if (arg = args.shift()) {
+ require(arg);
+ } else {
+ throw new Error('--require requires a path');
+ }
+ break;
+ case '-t':
+ case '--timeout':
+ if (arg = args.shift()) {
+ timeout = parseInt(arg, 10);
+ } else {
+ throw new Error('--timeout requires an argument');
+ }
+ break;
+ case '-c':
+ case '--cov':
+ case '--coverage':
+ defer = true;
+ childProcess.exec('rm -fr lib-cov && node-jscoverage lib lib-cov', function(err){
+ if (err) throw err;
+ require.paths.unshift('lib-cov');
+ run(files);
+ })
+ break;
+ case '-b':
+ case '--boring':
+ boring = true;
+ break;
+ case '-w':
+ case '--watch':
+ watch = true;
+ break;
+ case '-g':
+ case '--growl':
+ growl = true;
+ break;
+ case '-s':
+ case '--serial':
+ serial = true;
+ break;
+ default:
+ if (/\.js$/.test(arg)) {
+ files.push(arg);
+ }
+ break;
+ }
+}
+ |
+
+
+
+ Colorized sys.error().
+
+
+
+
+ |
+
+function print(str){
+ sys.error(colorize(str));
+}
+ |
+
+
+
+ Colorize the given string using ansi-escape sequences.
+Disabled when --boring is set.
+
+
+
+param: String str return: String
+ |
+
+function colorize(str){
+ var colors = { bold: 1, red: 31, green: 32, yellow: 33 };
+ return str.replace(/\[(\w+)\]\{([^]*?)\}/g, function(_, color, str){
+ return boring
+ ? str
+ : '\x1B[' + colors[color] + 'm' + str + '\x1B[0m';
+ });
+}
+
+
+
+assert.eql = assert.deepEqual;
+ |
+
+
+
+ Assert that val is null.
+
+
+
+param: Mixed val param: String msg
+ |
+
+assert.isNull = function(val, msg) {
+ assert.strictEqual(null, val, msg);
+};
+ |
+
+
+
+ Assert that val is not null.
+
+
+
+param: Mixed val param: String msg
+ |
+
+assert.isNotNull = function(val, msg) {
+ assert.notStrictEqual(null, val, msg);
+};
+ |
+
+
+
+ Assert that val is undefined.
+
+
+
+param: Mixed val param: String msg
+ |
+
+assert.isUndefined = function(val, msg) {
+ assert.strictEqual(undefined, val, msg);
+};
+ |
+
+
+
+ Assert that val is not undefined.
+
+
+
+param: Mixed val param: String msg
+ |
+
+assert.isDefined = function(val, msg) {
+ assert.notStrictEqual(undefined, val, msg);
+};
+ |
+
+
+
+ Assert that obj is type .
+
+
+
+param: Mixed obj param: String type api: public
+ |
+
+assert.type = function(obj, type, msg){
+ var real = typeof obj;
+ msg = msg || 'typeof ' + sys.inspect(obj) + ' is ' + real + ', expected ' + type;
+ assert.ok(type === real, msg);
+};
+ |
+
+
+
+ Assert that str matches regexp .
+
+
+
+param: String str param: RegExp regexp param: String msg
+ |
+
+assert.match = function(str, regexp, msg) {
+ msg = msg || sys.inspect(str) + ' does not match ' + sys.inspect(regexp);
+ assert.ok(regexp.test(str), msg);
+};
+ |
+
+
+
+ Assert that val is within obj .
+
+Examples
+
+ assert.includes('foobar', 'bar');
+ assert.includes(['foo', 'bar'], 'foo');
+
+
+
+
+ |
+
+assert.includes = function(obj, val, msg) {
+ msg = msg || sys.inspect(obj) + ' does not include ' + sys.inspect(val);
+ assert.ok(obj.indexOf(val) >= 0, msg);
+};
+ |
+
+
+
+ Assert length of val is n .
+
+
+
+param: Mixed val param: Number n param: String msg
+ |
+
+assert.length = function(val, n, msg) {
+ msg = msg || sys.inspect(val) + ' has length of ' + val.length + ', expected ' + n;
+ assert.equal(n, val.length, msg);
+};
+ |
+
+
+
+ Assert response from server with
+the given req object and res assertions object.
+
+
+
+
+ |
+
+assert.response = function(server, req, res, msg){
+
+ var callback = typeof res === 'function'
+ ? res
+ : typeof msg === 'function'
+ ? msg
+ : function(){};
+
+
+ if (typeof msg === 'function') msg = null;
+ msg = msg || assert.testTitle;
+ msg += '. ';
+
+
+ server.__pending = server.__pending || 0;
+ server.__pending++;
+
+
+ if (!server.fd) {
+ server.listen(server.__port = port++, '127.0.0.1');
+ server.client = http.createClient(server.__port);
+ }
+
+
+ var timer,
+ client = server.client,
+ method = req.method || 'GET',
+ status = res.status || res.statusCode,
+ data = req.data || req.body,
+ requestTimeout = req.timeout || 0;
+
+ var request = client.request(method, req.url, req.headers);
+
+
+ if (requestTimeout) {
+ timer = setTimeout(function(){
+ --server.__pending || server.close();
+ delete req.timeout;
+ assert.fail(msg + 'Request timed out after ' + requestTimeout + 'ms.');
+ }, requestTimeout);
+ }
+
+ if (data) request.write(data);
+ request.addListener('response', function(response){
+ response.body = '';
+ response.setEncoding('utf8');
+ response.addListener('data', function(chunk){ response.body += chunk; });
+ response.addListener('end', function(){
+ --server.__pending || server.close();
+ if (timer) clearTimeout(timer);
+
+
+ if (res.body !== undefined) {
+ var eql = res.body instanceof RegExp
+ ? res.body.test(response.body)
+ : res.body === response.body;
+ assert.ok(
+ eql,
+ msg + 'Invalid response body.\n'
+ + ' Expected: ' + sys.inspect(res.body) + '\n'
+ + ' Got: ' + sys.inspect(response.body)
+ );
+ }
+
+
+ if (typeof status === 'number') {
+ assert.equal(
+ response.statusCode,
+ status,
+ msg + colorize('Invalid response status code.\n'
+ + ' Expected: [green]{' + status + '}\n'
+ + ' Got: [red]{' + response.statusCode + '}')
+ );
+ }
+
+
+ if (res.headers) {
+ var keys = Object.keys(res.headers);
+ for (var i = 0, len = keys.length; i < len; ++i) {
+ var name = keys[i],
+ actual = response.headers[name.toLowerCase()],
+ expected = res.headers[name],
+ eql = expected instanceof RegExp
+ ? expected.test(actual)
+ : expected == actual;
+ assert.ok(
+ eql,
+ msg + colorize('Invalid response header [bold]{' + name + '}.\n'
+ + ' Expected: [green]{' + expected + '}\n'
+ + ' Got: [red]{' + actual + '}')
+ );
+ }
+ }
+
+
+ callback(response);
+ });
+ });
+ request.end();
+};
+ |
+
+
+
+ Pad the given string to the maximum width provided.
+
+
+
+param: String str param: Number width return: String
+ |
+
+function lpad(str, width) {
+ str = String(str);
+ var n = width - str.length;
+ if (n < 1) return str;
+ while (n--) str = ' ' + str;
+ return str;
+}
+ |
+
+
+
+ Pad the given string to the maximum width provided.
+
+
+
+param: String str param: Number width return: String
+ |
+
+function rpad(str, width) {
+ str = String(str);
+ var n = width - str.length;
+ if (n < 1) return str;
+ while (n--) str = str + ' ';
+ return str;
+}
+ |
+
+
+
+ Report test coverage.
+
+
+
+
+ |
+
+function reportCoverage(cov) {
+ populateCoverage(cov);
+
+ print('\n [bold]{Test Coverage}\n');
+ var sep = ' +------------------------------------------+----------+------+------+--------+',
+ lastSep = ' +----------+------+------+--------+';
+ sys.puts(sep);
+ sys.puts(' | filename | coverage | LOC | SLOC | missed |');
+ sys.puts(sep);
+ for (var name in cov) {
+ var file = cov[name];
+ if (Array.isArray(file)) {
+ sys.print(' | ' + rpad(name, 40));
+ sys.print(' | ' + lpad(file.coverage.toFixed(2), 8));
+ sys.print(' | ' + lpad(file.LOC, 4));
+ sys.print(' | ' + lpad(file.SLOC, 4));
+ sys.print(' | ' + lpad(file.totalMisses, 6));
+ sys.print(' |\n');
+ }
+ }
+ sys.puts(sep);
+ sys.print(' ' + rpad('', 40));
+ sys.print(' | ' + lpad(cov.coverage.toFixed(2), 8));
+ sys.print(' | ' + lpad(cov.LOC, 4));
+ sys.print(' | ' + lpad(cov.SLOC, 4));
+ sys.print(' | ' + lpad(cov.totalMisses, 6));
+ sys.print(' |\n');
+ sys.puts(lastSep);
+
+ for (var name in cov) {
+ if (name.match(/\.js$/)) {
+ var file = cov[name];
+ print('\n [bold]{' + name + '}:');
+ print(file.source);
+ sys.print('\n');
+ }
+ }
+}
+ |
+
+
+
+ Populate code coverage data.
+
+
+
+
+ |
+
+function populateCoverage(cov) {
+ cov.LOC =
+ cov.SLOC =
+ cov.totalFiles =
+ cov.totalHits =
+ cov.totalMisses =
+ cov.coverage = 0;
+ for (var name in cov) {
+ var file = cov[name];
+ if (Array.isArray(file)) {
+
+ ++cov.totalFiles;
+ cov.totalHits += file.totalHits = coverage(file, true);
+ cov.totalMisses += file.totalMisses = coverage(file, false);
+ file.totalLines = file.totalHits + file.totalMisses;
+ cov.SLOC += file.SLOC = file.totalLines;
+ if (!file.source) file.source = [];
+ cov.LOC += file.LOC = file.source.length;
+ file.coverage = (file.totalHits / file.totalLines) * 100;
+
+ var width = file.source.length.toString().length;
+ file.source = file.source.map(function(line, i){
+ ++i;
+ var hits = file[i] === 0 ? 0 : (file[i] || ' ');
+ if (!boring) {
+ if (hits === 0) {
+ hits = '\x1b[31m' + hits + '\x1b[0m';
+ line = '\x1b[41m' + line + '\x1b[0m';
+ } else {
+ hits = '\x1b[32m' + hits + '\x1b[0m';
+ }
+ }
+ return '\n ' + lpad(i, width) + ' | ' + hits + ' | ' + line;
+ }).join('');
+ }
+ }
+ cov.coverage = (cov.totalHits / cov.SLOC) * 100;
+}
+ |
+
+
+
+ Total coverage for the given file data.
+
+
+
+param: Array data return: Type
+ |
+
+function coverage(data, val) {
+ var n = 0;
+ for (var i = 0, len = data.length; i < len; ++i) {
+ if (data[i] !== undefined && data[i] == val) ++n;
+ }
+ return n;
+}
+ |
+
+
+
+ Run the given test files , or try test/*.
+
+
+
+
+ |
+
+function run(files) {
+ if (!files.length) {
+ try {
+ files = fs.readdirSync('test').map(function(file){
+ return 'test/' + file;
+ });
+ } catch (err) {
+ print('\n failed to load tests in [bold]{./test}\n');
+ ++failures;
+ process.exit(1);
+ }
+ }
+ if (watch) watchFiles(files);
+ runFiles(files);
+}
+ |
+
+
+
+ Show the cursor when show is true, otherwise hide it.
+
+
+
+
+ |
+
+function cursor(show) {
+ if (show) {
+ sys.print('\x1b[?25h');
+ } else {
+ sys.print('\x1b[?25l');
+ }
+}
+ |
+
+
+
+ Run the given test files .
+
+
+
+
+ |
+
+function runFiles(files) {
+ if (serial) {
+ (function next(){
+ if (files.length) {
+ runFile(files.shift(), next);
+ }
+ })();
+ } else {
+ files.forEach(runFile);
+ }
+}
+ |
+
+
+
+ Run tests for the given file , callback fn() when finished.
+
+
+
+param: String file param: Function fn
+ |
+
+function runFile(file, fn) {
+ if (file.match(/\.js$/)) {
+ var title = path.basename(file),
+ file = path.join(cwd, file),
+ mod = require(file.replace(/\.js$/, ''));
+ (function check(){
+ var len = Object.keys(mod).length;
+ if (len) {
+ runSuite(title, mod, fn);
+ } else {
+ setTimeout(check, 20);
+ }
+ })();
+ }
+}
+ |
+
+
+
+ Clear the module cache for the given file .
+
+
+
+
+ |
+
+function clearCache(file) {
+ var keys = Object.keys(module.moduleCache);
+ for (var i = 0, len = keys.length; i < len; ++i) {
+ var key = keys[i];
+ if (key.indexOf(file) === key.length - file.length) {
+ delete module.moduleCache[key];
+ }
+ }
+}
+ |
+
+
+
+ Watch the given files for changes.
+
+
+
+
+ |
+
+function watchFiles(files) {
+ var p = 0,
+ c = ['â« ', 'â«â« ', 'â«â«â« ', ' â«â«â«',
+ ' â«â«', ' â«', ' â«', ' â«â«',
+ 'â«â«â« ', 'â«â« ', 'â« '],
+ l = c.length;
+ cursor(false);
+ setInterval(function(){
+ sys.print(colorize(' [green]{' + c[p++ % l] + '} watching\r'));
+ }, 100);
+ files.forEach(function(file){
+ fs.watchFile(file, { interval: 100 }, function(curr, prev){
+ if (curr.mtime > prev.mtime) {
+ print(' [yellow]{â¦} ' + file);
+ clearCache(file);
+ runFile(file);
+ }
+ });
+ });
+}
+ |
+
+
+
+ Report err for the given test and suite .
+
+
+
+param: String suite param: String test param: Error err
+ |
+
+function error(suite, test, err) {
+ ++failures;
+ var name = err.name,
+ stack = err.stack.replace(err.name, ''),
+ label = test === 'uncaught'
+ ? test
+ : suite + ' ' + test;
+ print('\n [bold]{' + label + '}: [red]{' + name + '}' + stack + '\n');
+ if (watch) notify(label + ' failed');
+}
+ |
+
+
+
+ Run the given tests, callback fn() when finished.
+
+
+
+param: String title param: Object tests param: Function fn
+ |
+
+var dots = 0;
+function runSuite(title, tests, fn) {
+
+ var keys = only.length
+ ? only.slice(0)
+ : Object.keys(tests);
+
+
+ var setup = tests.setup || function(fn){ fn(); };
+
+
+ (function next(){
+ if (keys.length) {
+ var key,
+ test = tests[key = keys.shift()];
+
+ if (key === 'setup') return next();
+
+
+ if (test) {
+ try {
+ ++testcount;
+ assert.testTitle = key;
+ if (serial) {
+ if (!watch) {
+ sys.print('.');
+ if (++dots % 25 === 0) sys.print('\n');
+ }
+ setup(function(){
+ if (test.length < 1) {
+ test();
+ next();
+ } else {
+ var id = setTimeout(function(){
+ throw new Error("'" + key + "' timed out");
+ }, timeout);
+ test(function(){
+ clearTimeout(id);
+ next();
+ });
+ }
+ });
+ } else {
+ test(function(fn){
+ process.addListener('beforeExit', function(){
+ try {
+ fn();
+ } catch (err) {
+ error(title, key, err);
+ }
+ });
+ });
+ }
+ } catch (err) {
+ error(title, key, err);
+ }
+ }
+ if (!serial) next();
+ } else if (serial) {
+ fn();
+ }
+ })();
+}
+ |
+
+
+
+ Report exceptions.
+
+ |
+
+function report() {
+ process.emit('beforeExit');
+ if (failures) {
+ print('\n [bold]{Failures}: [red]{' + failures + '}\n\n');
+ notify('Failures: ' + failures);
+ } else {
+ if (serial) print('');
+ print('\n [green]{100%} ' + testcount + ' tests\n');
+ notify('100% ok');
+ }
+ if (typeof _$jscoverage === 'object') {
+ reportCoverage(_$jscoverage);
+ }
+}
+ |
+
+
+
+ Growl notify the given msg .
+
+
+
+
+ |
+
+function notify(msg) {
+ if (growl) {
+ childProcess.exec('growlnotify -name Expresso -m "' + msg + '"');
+ }
+}
+
+
+
+process.addListener('uncaughtException', function(err){
+ error('uncaught', 'uncaught', err);
+});
+
+
+
+['INT', 'TERM', 'QUIT'].forEach(function(sig){
+ process.addListener('SIG' + sig, function(){
+ cursor(true);
+ process.exit(1);
+ });
+});
+
+
+
+
+
+var orig = process.emit;
+process.emit = function(event){
+ if (event === 'exit') {
+ report();
+ process.reallyExit(failures);
+ }
+ orig.apply(this, arguments);
+};
+
+
+
+if (!defer) run(files);
+
+ |
+
+