#!/usr/bin/env node /*! * Expresso * Copyright(c) TJ Holowaychuk * (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.4.0'; /** * Failures hash. */ var failures = {}; /** * Failure count. */ var failed = 0; /** * Whitelist of tests to run. */ var only = []; /** * Boring output. */ var boring = false; /** * Growl notifications. */ var growl = false; /** * Server port. */ var port = 5555; /** * Usage documentation. */ var usage = '' + '[bold]{Usage}: expresso [options] ' + '\n' + '\n[bold]{Options}:' + '\n -g, --growl Enable growl notifications' + '\n -c, --coverage Generate and report test coverage' + '\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 -b, --boring Suppress ansi-escape colors' + '\n -v, --version Output version number' + '\n -h, --help Display help information' + '\n'; // Parse arguments 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 '-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 '--g': case '--growl': growl = true; break; default: if (/\.js$/.test(arg)) { files.push(arg); } break; } } /** * Colorized sys.error(). * * @param {String} str */ 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 }; return str.replace(/\[(\w+)\]\{([^]*?)\}/g, function(_, color, str){ return boring ? str : '\x1B[' + colors[color] + 'm' + str + '\x1B[0m'; }); } // Alias deepEqual as eql for complex equality assert.eql = assert.deepEqual; /** * Assert that `val` is null. * * @param {Mixed} val * @param {String} msg * @api public */ assert.isNull = function(val, msg) { assert.strictEqual(null, val, msg); }; /** * Assert that `val` is not null. * * @param {Mixed} val * @param {String} msg * @api public */ assert.isNotNull = function(val, msg) { assert.notStrictEqual(null, val, msg); }; /** * Assert that `val` is undefined. * * @param {Mixed} val * @param {String} msg * @api public */ assert.isUndefined = function(val, msg) { assert.strictEqual(undefined, val, msg); }; /** * Assert that `val` is not undefined. * * @param {Mixed} val * @param {String} msg * @api public */ assert.isNotUndefined = function(val, msg) { assert.notStrictEqual(undefined, val, msg); }; /** * Assert that `str` matches `regexp`. * * @param {String} str * @param {RegExp} regexp * @param {String} msg * @api public */ 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 `substr` is within `str`. * * @param {String} str * @param {String} substr * @param {String} msg * @api public */ assert.includes = function(str, substr, msg) { msg = msg || sys.inspect(str) + ' does not include ' + sys.inspect(substr); assert.ok(str.indexOf(substr) >= 0, msg); }; /** * Assert length of `val` is `n`. * * @param {Number} n * @param {Mixed} val * @param {String} msg * @api public */ assert.length = function(n, val, 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. * * @param {Server} server * @param {Object} req * @param {Object|Function} res * @param {String} msg * @api public */ assert.response = function(server, req, res, msg){ msg = msg || assert.testTitle; msg += '. '; server.__pending = server.__pending || 0; server.__pending++; if (!server.fd) { server.listen(server.__port = port++); server.client = http.createClient(server.__port); } var client = server.client, method = req.method || 'GET', status = res.status || res.statusCode, data = req.data || req.body; var request = client.request(method, req.url, req.headers); 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(); // Assertion callback if (typeof res === 'function') { return res(response); } // Assert response body if (res.body !== undefined) { assert.equal( response.body, res.body, msg + 'Invalid response body.\n' + ' Expected: ' + sys.inspect(res.body) + '\n' + ' Got: ' + sys.inspect(response.body) ); } // Assert response status if (typeof status === 'number') { assert.equal( response.statusCode, status, msg + colorize('Invalid response status code.\n' + ' Expected: [green]{' + status + '}\n' + ' Got: [red]{' + response.statusCode + '}') ); } // Assert response headers 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]; assert.equal( actual, expected, msg + colorize('Invalid response header [bold]{' + name + '}.\n' + ' Expected: [green]{' + expected + '}\n' + ' Got: [red]{' + actual + '}') ); } } }); }); request.end(); }; /** * Pad the given string to the maximum width provided. * * @param {String} str * @param {Number} width * @return {String} * @api private */ 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} * @api private */ 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. * * @param {Object} cov * @api private */ function reportCoverage(cov) { populateCoverage(cov); // Stats 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 (file instanceof Array) { 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); // Source 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. * * @param {Object} cov * @api private */ 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 (file instanceof Array) { // Stats ++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; cov.LOC += file.LOC = file.source.length; file.coverage = (file.totalHits / file.totalLines) * 100; // Source 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} * @api private */ 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 all test files. * * @param {Array} files * @api public */ function run(files, dir) { if (files.length) { files.forEach(function(file){ var title = path.basename(file), file = path.join(cwd, dir, file), mod = require(file.replace(/\.js$/, '')); runSuite(title, mod); }); } else { fs.readdir('test', function(err, files){ if (err) { print('\n failed to load tests in [bold]{./test}\n'); ++failed; process.exit(1); } run(files, 'test/'); }); } } /** * Report global failures. * * @api private */ function reportFailures() { for (var suite in failures) { var n = 0; print('\n --- [bold]{' + suite + '} ' + Array(55).join('-') + '\n'); for (var test in failures[suite]) { var err = failures[suite][test], name = err.name, stack = err.stack.replace(err.name, ''); if (test.indexOf('uncaught') === 0) { test = ''; } print('\n ' + ++n + ') [bold]{' + test + '}: [red]{' + name + '}' + stack + '\n'); } } } /** * Run the given tests. * * @param {String} title * @param {Object} tests * @api private */ function runSuite(title, tests) { var keys = only.length ? only.slice(0) : Object.keys(tests); (function next(){ if (keys.length) { var key, test = tests[key = keys.shift()]; if (test) { try { assert.testTitle = key; test(assert, function(fn){ process.addListener('beforeExit', function(){ try { fn(); } catch (err) { ++failed; failures[title] = failures[title] || {}; failures[title][key] = err; } }); }); } catch (err) { ++failed; failures[title] = failures[title] || {}; failures[title][key] = err; } } next(); } })(); } /** * Growl notify the given `msg`. * * @param {String} msg */ function notify(msg) { if (growl) { childProcess.exec('growlnotify -name Expresso -m "' + msg + '"'); } } // Prefix uncaughtException's with "uncaught" var uncaught = 0; process.addListener('uncaughtException', function(err){ ++failed; failures['uncaught'] = failures['uncaught'] || {}; failures['uncaught']['uncaught ' + ++uncaught] = err; }); // Report test coverage when available // and emit "beforeExit" event to perform // final assertions var orig = process.emit; process.emit = function(event){ if (event === 'exit') { process.emit('beforeExit'); reportFailures(); if (failed) { print('\n [bold]{Failures}: [red]{' + failed + '}\n\n'); notify('Failures: ' + failed); } else { var quote = [ 's`all good', 'eyyyy-ok', 'wahoo, your shit is not broken!', 'TATFT', ':)' ][Math.random() * 5 | 0]; print('\n [green]{100%} [bold]{' + quote + '}\n'); notify('100% ' + quote); } if (typeof _$jscoverage === 'object') { reportCoverage(_$jscoverage); } process.reallyExit(failed); } orig.apply(this, arguments); }; // Run test files if (!defer) run(files);