#!/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.7.2'; /** * 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; /** * Execute serially. */ var serial = false; /** * Default timeout. */ var timeout = 2000; /** * Quiet output. */ var quiet = false; /** * 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 -q, --quiet Suppress coverage report if 100%' + '\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'; // 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 '-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 '-q': case '--quiet': quiet = true; break; case '-b': case '--boring': boring = 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(). * * @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, yellow: 33 }; 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 */ 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'); * * @param {String|Array} obj * @param {Mixed} val * @param {String} msg */ 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. * * @param {Server} server * @param {Object} req * @param {Object|Function} res * @param {String} msg */ assert.response = function(server, req, res, msg){ // Check that the server is ready or defer if (!server.fd) { if (!('__deferred' in server)) { server.__deferred = []; } server.__deferred.push(arguments); if (!server.__started) { server.listen(server.__port = port++, '127.0.0.1', function(){ if (server.__deferred) { process.nextTick(function(){ server.__deferred.forEach(function(args){ assert.response.apply(assert, args); }); }); } }); server.__started = true; } return; } // Callback as third or fourth arg var callback = typeof res === 'function' ? res : typeof msg === 'function' ? msg : function(){}; // Default messate to test title if (typeof msg === 'function') msg = null; msg = msg || assert.testTitle; msg += '. '; // Pending responses server.__pending = server.__pending || 0; server.__pending++; // Create client if (!server.fd) { server.listen(server.__port = port++, '127.0.0.1', issue); } else { issue(); } function issue(){ if (!server.client) server.client = http.createClient(server.__port); // Issue request 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); // Timeout 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.on('response', function(response){ response.body = ''; response.setEncoding('utf8'); response.on('data', function(chunk){ response.body += chunk; }); response.on('end', function(){ --server.__pending || server.close(); if (timer) clearTimeout(timer); // Assert response body 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) ); } // 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], 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 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. * * @param {Object} cov */ function reportCoverage(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 (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); // Source for (var name in cov) { if (name.match(/\.js$/)) { var file = cov[name]; if ((file.coverage < 100) || !quiet) { print('\n [bold]{' + name + '}:'); print(file.source); sys.print('\n'); } } } } /** * Populate code coverage data. * * @param {Object} cov */ 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)) { // 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; if (!file.source) file.source = []; 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} */ 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; } /** * Test if all files have 100% coverage * * @param {Object} cov * @return {Boolean} */ function hasFullCoverage(cov) { for (var name in cov) { var file = cov[name]; if (file instanceof Array) { if (file.coverage !== 100) { return false; } } } return true; } /** * Run the given test `files`, or try _test/*_. * * @param {Array} files */ function run(files) { cursor(false); 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); } } runFiles(files); } /** * Show the cursor when `show` is true, otherwise hide it. * * @param {Boolean} show */ function cursor(show) { if (show) { sys.print('\x1b[?25h'); } else { sys.print('\x1b[?25l'); } } /** * Run the given test `files`. * * @param {Array} 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); } })(); } } /** * 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 ? err.stack.replace(err.name, '') : '', label = test === 'uncaught' ? test : suite + ' ' + test; print('\n [bold]{' + label + '}: [red]{' + name + '}' + stack + '\n'); } /** * 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) { // Keys var keys = only.length ? only.slice(0) : Object.keys(tests); // Setup var setup = tests.setup || function(fn){ fn(); }; // Iterate tests (function next(){ if (keys.length) { var key, test = tests[key = keys.shift()]; // Non-tests if (key === 'setup') return next(); // Run test if (test) { try { ++testcount; assert.testTitle = key; if (serial) { 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.on('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() { cursor(true); 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') { populateCoverage(_$jscoverage); if (!hasFullCoverage(_$jscoverage) || !quiet) { reportCoverage(_$jscoverage); } } } /** * Growl notify the given `msg`. * * @param {String} msg */ function notify(msg) { if (growl) { childProcess.exec('growlnotify -name Expresso -m "' + msg + '"'); } } // Report uncaught exceptions process.on('uncaughtException', function(err){ error('uncaught', 'uncaught', err); }); // Show cursor ['INT', 'TERM', 'QUIT'].forEach(function(sig){ process.on('SIG' + sig, function(){ cursor(true); process.exit(1); }); }); // Report test coverage when available // and emit "beforeExit" event to perform // final assertions var orig = process.emit; process.emit = function(event){ if (event === 'exit') { report(); process.reallyExit(failures); } orig.apply(this, arguments); }; // Run test files if (!defer) run(files);