Skip to content

Instantly share code, notes, and snippets.

@abriening
Created November 10, 2011 16:09
Show Gist options
  • Save abriening/1355236 to your computer and use it in GitHub Desktop.
Save abriening/1355236 to your computer and use it in GitHub Desktop.
task :test => "test:javascript"
namespace :test do
task :javascript, :expresso_args do |t, args|
node = `/usr/bin/env which node`
if node == ""
puts "Skipping JavaScript unit tests: no 'node' executable found."
puts "Please install node.js v0.4.0 or newer."
else
puts "Running JavaScript unit tests:"
expresso = "test/javascript/support/expresso"
extra_args = args[:expresso_args] || ""
include_path = "public/javascripts"
test_scripts = "test/javascript/*.js"
Dir.chdir(Rails.root) do
sh "NODE_PATH=#{include_path} #{expresso} #{extra_args} #{test_scripts} 2>&1"
end
end
end
end
// Support for JS unit tests: expose testable interfaces by assigning them
// as properties on "exports". This allows them to be imported by node.js
// during testing, and the code below keeps it browser-compatible.
if(typeof exports == "undefined") exports = {};
// exports is used by node.js & the expresso tests
var userValidator = exports.userValidator = function(params){
// returns the validation closure
}
// can export more than one object from this file
var adminValidator = exports.adminValidator = function(params){
// returns the validation closure
}
#!/usr/bin/env node
/*
* Expresso
* Copyright(c) TJ Holowaychuk <[email protected]>
* (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;
/**
* 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;
/**
* 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) >= 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){
function check(){
try {
server.__port = server.address().port;
server.__listening = true;
} catch (err) {
process.nextTick(check);
return;
}
if (server.__deferred) {
server.__deferred.forEach(function(args){
assert.response.apply(assert, args);
});
server.__deferred = null;
}
}
// 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(arguments);
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(){
// 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;
assert.fail(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);
// 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);
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 < 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 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 < 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 = {
"coverage" : cov.coverage.toFixed(2),
"LOC" : cov.LOC,
"SLOC" : cov.SLOC,
"totalMisses" : cov.totalMisses,
"files" : {}
};
for (var name in cov) {
var file = cov[name];
if (Array.isArray(file)) {
report.files[name] = {
"coverage" : file.coverage.toFixed(2),
"LOC" : file.LOC,
"SLOC" : file.SLOC,
"totalMisses" : file.totalMisses
};
}
}
fs.writeFileSync(jsonFile, JSON.stringify(report), "utf8");
}
/**
* 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 (boring) return;
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(file_matcher)) {
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);
} 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(); };
var teardown = tests.teardown || function(fn){ fn(); };
process.setMaxListeners(10 + process.listeners('beforeExit').length + keys.length);
// Iterate tests
(function next(){
if (keys.length) {
var key,
test = tests[key = keys.shift()];
// Non-tests
if (key === 'setup' || key === 'teardown') 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();
teardown(next);
} else {
var id = setTimeout(function(){
throw new Error("'" + key + "' timed out");
}, timeout);
test(function(){
clearTimeout(id);
teardown(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) {
(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
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);
// a contrived example of a user validator test
var assert = require("assert");
var user = require("user");
var userValidator = user.userValidator;
exports["user validations"] = function() {
assert.eql(false, userValidator({}).valid());
assert.eql(true, userValidator({email:"[email protected]"}).valid());
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment