# Expresso

Insanely fast TDD framework for node featuring code coverage reporting.

## expresso

bin/expresso

# !/usr/bin/env node

     * Expresso
     * Copyright(c) TJ Holowaychuk &lt;tj@vision-media.ca&gt;
     * (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;

Set the node env to test if it hasn't already been set

    if( !process.env.NODE_ENV ) process.env.NODE_ENV = 'test';

Setup the regex which is used to match test files. Adjust it to include coffeescript files if CS is available

    var file_matcher = /\.js$/;
    try {
      require('coffee-script');
      file_matcher = /\.(js|coffee)$/;
    } catch (e) {}

Expresso version.

    var version = '0.8.1';

Failure count.

    var failures = 0;

Indicates whether all test files have been loaded.

    var suites = 0;
    var suitesRun = 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;

JSON code coverage report

    var jsonCoverage = false;
    var jsonFile;

Usage documentation.

    var usage = ''
        + '[bold]{Usage}: expresso [options] <file ...>'
        + '\n'
        + '\n[bold]{Options}:'
        + '\n  -g, --growl          Enable growl notifications'
        + '\n  -c, --coverage       Generate and report test coverage'
        + '\n  -j, --json PATH      Used in conjunction with --coverage, ouput JSON coverage to PATH'
        + '\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;
            case '-j':
            case '--json':
                jsonCoverage = true;
                if (arg = args.shift()) {
                    jsonFile = path.normalize(arg);
                } else {
                    throw new Error('--json requires file to write to');
                }
                break;
            default:
                if (file_matcher.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) &gt;= 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) {
        var test = assert._test;

        // 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 || test.title;
        msg += '. ';

        // Add a unique token for this call to assert.response(). We'll move it to
        // succeeded/failed when done
        var token = new Error('Response not completed: ' + msg);
        test._pending.push(token);

        function check() {
            try {
                server.__port = server.address().port;
                server.__listening = true;
            } catch (err) {
                process.nextTick(check);
                return;
            }
            if (server.__deferred) {
                server.__deferred.forEach(function(fn) { fn(); });
                server.__deferred = null;
            }
        }

        // Pending responses
        server.__pending = server.__pending || 0;
        server.__pending++;

        // Check that the server is ready or defer
        if (!server.fd) {
            server.__deferred = server.__deferred || [];
            server.listen(server.__port = port++, '127.0.0.1', check);
        } else if (!server.__port) {
            server.__deferred = server.__deferred || [];
            process.nextTick(check);
        }

        // The socket was created but is not yet listening, so keep deferring
        if (!server.__listening) {
            server.__deferred.push(issue);
            return;
        } else {
            issue();
        }

        function issue() {
            // Issue request
            var timer,
                method = req.method || 'GET',
                status = res.status || res.statusCode,
                data = req.data || req.body,
                requestTimeout = req.timeout || 0,
                encoding = req.encoding || 'utf8';

            var request = http.request({
                host: '127.0.0.1',
                port: server.__port,
                path: req.url,
                method: method,
                headers: req.headers
            });

            var check = function() {
                if (--server.__pending === 0) {
                    server.close();
                    server.__listening = false;
                }
            };

            // Timeout
            if (requestTimeout) {
                timer = setTimeout(function() {
                    check();
                    delete req.timeout;
                    test.failure(new Error(msg + 'Request timed out after ' + requestTimeout + 'ms.'));
                }, requestTimeout);
            }

            if (data) request.write(data);

            request.on('response', function(response) {
                response.body = '';
                response.setEncoding(encoding);
                response.on('data', function(chunk) { response.body += chunk; });
                response.on('end', function() {
                    if (timer) clearTimeout(timer);
                    try {
                        // 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 &lt; 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);

                        // Add this to the succeeded bin.
                        test.success(msg);
                    } catch (err) {
                        test.failure(err);
                        test.callback();
                    } finally {
                        // Remove our token.
                        var idx = test._pending.indexOf(token);
                        if (idx &gt;= 0) {
                            test._pending.splice(idx, 1);
                        } else {
                            // Someone else took our token. This is an error.
                            test.failure(new Error('Request succeeded, but token vanished: ' + msg));
                        }

                        // Potentially shut down the server.
                        check();
                    }
                });
            });

            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 &lt; 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 &lt; 1) return str;
        while (n--) str = str + ' ';
        return str;
    }

Report test coverage in tabular format

##

- param: Object cov

    function reportCoverageTable(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(file_matcher)) {
                var file = cov[name];
                if ((file.coverage &lt; 100) || !quiet) {
                   print('\n   [bold]{' + name + '}:');
                   print(file.source);
                   sys.print('\n');
                }
            }
        }
    }

Report test coverage in raw json format

##

- param: Object cov

    function reportCoverageJson(cov) {
        var report = {
            &quot;coverage&quot; : cov.coverage.toFixed(2),
            &quot;LOC&quot; : cov.LOC,
            &quot;SLOC&quot; : cov.SLOC,
            &quot;totalMisses&quot; : cov.totalMisses,
            &quot;files&quot; : {}
        };

        for (var name in cov) {
            var file = cov[name];
            if (Array.isArray(file)) {
                report.files[name] = {
                    &quot;coverage&quot; : file.coverage.toFixed(2),
                    &quot;LOC&quot; : file.LOC,
                    &quot;SLOC&quot; : file.SLOC,
                    &quot;totalMisses&quot; : file.totalMisses
                };
            }
        }

        fs.writeFileSync(jsonFile, JSON.stringify(report), &quot;utf8&quot;);
    }

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 &lt; len; ++i) {
            if (data[i] !== undefined &amp;&amp; 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;
                }).filter(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 (boring) return;
        if (show) {
            sys.print('\x1b[?25h');
        } else {
            sys.print('\x1b[?25l');
        }
    }

Run the given test `files`.

##

- param: Array files

    function runFiles(files) {
        files = files.filter(function(file) {
            return file.match(file_matcher);
        });
        suites = files.length;

        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) {
        var title = path.basename(file),
            file = path.join(cwd, file),
            mod = require(file.replace(file_matcher,''));
        (function check() {
           var len = Object.keys(mod).length;
           if (len) {
               runSuite(title, mod, fn);
               suitesRun++;
           } else {
               setTimeout(check, 20);
           }
        })();
    }

Run the given tests, callback `fn()` when finished.

##

- param: String title

- param: Object tests

- param: Function fn

    var dots = 0;
    function runSuite(title, tests, callback) {
        // Keys
        var keys = only.length
            ? only.slice(0)
            : Object.keys(tests);

        // Setup
        var setup = tests.setup || function(fn, assert) { fn(); };
        var teardown = tests.teardown || function(fn, assert) { fn(); };

        process.setMaxListeners(10 + process.listeners('beforeExit').length  + keys.length);

        // Iterate tests
        (function next() {
            if (keys.length) {
                var key,
                    fn = tests[key = keys.shift()];
                // Non-tests
                if (key === 'setup' || key === 'teardown') return next();

                // Run test
                if (fn) {
                    var test = new Test({
                        fn: fn,
                        suite: title,
                        title: key,
                        setup: setup,
                        teardown: teardown
                    })
                    test.run(next);
                } else {
                    // @TODO: Add warning message that there's no test.
                    next();
                }
            } else if (serial) {
                callback();
            }
        })();
    }

    require('util').inherits(Test, require('events').EventEmitter);
    function Test(options) {
        for (var key in options) {
            this[key] = options[key];
        }
        this._succeeded = [];
        this._failed = [];
        this._pending = [];
        this._beforeExit = [];
        this.assert = { __proto__: assert, _test: this };

        var test = this;
        process.on('beforeExit', function() {
            try {
                test.emit('exit');
            } catch (err) {
                test.failure(err);
            }
            test.report();
        });
    }

    Test.prototype.success = function(err) {
        this._succeeded.push(err);
    };

    Test.prototype.failure = function(err) {
        this._failed.push(err);
        this.error(err);
    };

    Test.prototype.report = function() {
        for (var i = 0; i &lt; this._pending.length; i++) {
            this.error(this._pending[i]);
        }
    };

    Test.prototype.run = function(callback) {
        try {
            ++testcount;
            assert._test = this;

            if (serial) {
                this.runSerial(callback);
            } else {
                // @TODO: find a way to run setup/tearDown.
                this.runParallel();
                callback();
            }
        } catch (err) {
            this.failure(err);
            this.report();
        }
    };

    Test.prototype.runSerial = function(callback) {
        var test = this;
        sys.print('.');
        if (++dots % 25 === 0) sys.print('\n');
        test.setup(function() {
            if (test.fn.length &lt; 1) {
                test.fn();
                test.teardown(callback);
            } else {
                var id = setTimeout(function() {
                    throw new Error(&quot;'" + key + "' timed out&quot;);
                }, timeout);
                test.callback = function() {
                    clearTimeout(id);
                    test.teardown(callback);
                    process.nextTick(function() {
                        test.report();
                    });
                };
                test.fn(test.callback);
            }
        });
    };

    Test.prototype.runParallel = function() {
        var test = this;
        test.fn(function(fn) {
            test.on('exit', function() {
                fn(test.assert);
            });
        }, test.assert);
    };

Report `err` for the given `test` and `suite`.

##

- param: String suite

- param: String test

- param: Error err

    Test.prototype.error = function(err) {
        if (!err._reported) {
            ++failures;
            var name = err.name,
                stack = err.stack ? err.stack.replace(err.name, '') : '',
                label = this.title === 'uncaught'
                    ? this.title
                    : this.suite + ' ' + this.title;
            print('\n   [bold]{' + label + '}: [red]{' + name + '}' + stack + '\n');
            err._reported = true;
        }
    }

Report exceptions.

    function report() {
        cursor(true);
        process.emit('beforeExit');
        if (suitesRun &lt; suites) {
            print('\n   [bold]{Failure}: [red]{Only ' + suitesRun + ' of ' + suites + ' suites have been started}\n\n');
        }
        else 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) {
                (jsonCoverage ? reportCoverageJson(_$jscoverage) : reportCoverageTable(_$jscoverage));
            }
        }
    }

Growl notify the given `msg`.

##

- param: String msg

    function notify(msg) {
        if (growl) {
            childProcess.exec('growlnotify -name Expresso -m "' + msg + '"');
        }
    }

    // Report uncaught exceptions
    var unknownTest = new Test({
        suite: 'uncaught',
        test: 'uncaught'
    });

    process.on('uncaughtException', function(err) {
        unknownTest.error(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);