Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Prelyminary code coverage support using runforcover and bunker.

  • Loading branch information...
commit 68f6a3553336b3ce1bdbba71b69b44914ce5dc25 1 parent 62c5653
@kusor kusor authored committed
View
1  .gitignore
@@ -1 +1,2 @@
node_modules/*
+coverage-example/coverage/*
View
4 README.md
@@ -74,5 +74,9 @@ this is very useful for CI systems and such.
* tap-global-harness: A default harness that provides the top-level
support for running TAP tests.
+Code Coverage with runforcover & bunker:
+
+`TAP_COV=1 tap ./tests [--cover=./lib,foo.js] [--cover-dir=./coverage]`
+
More docs coming soon, hopefully.
View
15 coverage-example/lib/bar.js
@@ -0,0 +1,15 @@
+var Bar = module.exports = function(str) {
+ this.bar = str;
+ this.str = str;
+};
+
+Bar.prototype.foo = function() {
+ var self = this;
+ return self.bar;
+};
+
+Bar.prototype.baz = function() {
+ var self = this;
+ return self.str;
+};
+
View
15 coverage-example/lib/foo.js
@@ -0,0 +1,15 @@
+var Foo = module.exports = function(str) {
+ this.foo = str;
+ this.str = str;
+};
+
+Foo.prototype.bar = function() {
+ var self = this;
+ return self.foo;
+};
+
+Foo.prototype.baz = function() {
+ var self = this;
+ return self.str;
+};
+
View
1  coverage-example/node_modules/tap
View
20 coverage-example/test/bar.test.js
@@ -0,0 +1,20 @@
+var test = require('tap').test,
+ Bar = require('../lib/bar'),
+ bar;
+
+test('setup', function(t) {
+ bar = new Bar('baz');
+ t.ok(bar);
+ t.end();
+});
+
+test('bar', function(t) {
+ t.equal('baz', bar.foo());
+ t.end();
+});
+
+test('teardown', function(t) {
+ t.ok(true);
+ t.end();
+});
+
View
29 coverage-example/test/baz.test.js
@@ -0,0 +1,29 @@
+var test = require('tap').test,
+ Foo = require('../lib/foo'),
+ Bar = require('../lib/bar'),
+ foo, bar;
+
+test('setup', function(t) {
+ foo = new Foo('baz');
+ t.ok(foo);
+ bar = new Bar('baz');
+ t.ok(bar);
+ t.end();
+});
+
+test('baz from Foo', function(t) {
+ t.equal('baz', foo.baz());
+ t.end();
+});
+
+test('baz from Bar', function(t) {
+ t.equal('baz', bar.baz());
+ t.end();
+});
+
+
+test('teardown', function(t) {
+ t.ok(true);
+ t.end();
+});
+
View
20 coverage-example/test/foo.test.js
@@ -0,0 +1,20 @@
+var test = require('tap').test,
+ Foo = require('../lib/foo'),
+ foo;
+
+test('setup', function(t) {
+ foo = new Foo('baz');
+ t.ok(foo);
+ t.end();
+});
+
+test('bar', function(t) {
+ t.equal('baz', foo.bar());
+ t.end();
+});
+
+test('teardown', function(t) {
+ t.ok(true);
+ t.end();
+});
+
View
76 lib/tap-cov-html.js
@@ -0,0 +1,76 @@
+var fs = require('fs'),
+ path = require('path'),
+ asyncMap = require("slide").asyncMap,
+ util = require('util');
+
+var CovHtml = module.exports = function(cov_stats, cov_dir, cb) {
+ var index = [];
+
+ asyncMap(
+ Object.keys(cov_stats),
+ function(f, cb) {
+ var st = cov_stats[f],
+ missing_lines = st.missing.map(function(l) {
+ return l.number;
+ }),
+ out = '<!doctype html>\n<html lang="en">\n<head>\n ' +
+ '<meta charset="utf-8">\n <title>' +
+
+ f + ' (' + st.loc + ')</title>\n' +
+ '<style type="text/css">\n' +
+ 'li {\n' +
+ ' font-family: monospace;\n' +
+ ' white-space: pre;\n' +
+ '}\n' +
+ '</style>\n' +
+ '</head>\n<body>\n' +
+ '<h1>' + f + ' (' + st.loc + ')' + '</h1>' +
+ '<h2>Run: ' + (st.missing.length ? st.loc - st.missing.length : st.loc) + ', Missing: ' +
+ st.missing.length + ', Percentage: ' + st.percentage + '</h2>' +
+ '<h2>Source:</h2>\n' +
+ '<ol>\n' +
+ st.lines.map(function(line) {
+ var number = line.number,
+ color = (missing_lines.indexOf(number) !== -1) ? 'red' : 'green';
+ return '<li id="L' + line.number + '" style="background-color: ' + color +
+ ';">' + line.source + '</li>';
+ }).join('\n') +
+ '</ol>\n</body>\n</html>';
+
+ fs.writeFile(
+ cov_dir + '/' +
+ f.replace(process.cwd() + '/', '').replace('/', '+') + '.html',
+ out,
+ 'utf8',
+ function(err) {
+ if (err) {
+ throw err;
+ }
+ index.push(f);
+ cb();
+ });
+ },
+ function(err) {
+ if (err) {
+ throw err;
+ }
+ var out = '<!doctype html>\n<html lang="en">\n<head>\n ' +
+ '<meta charset="utf-8">\n <title>Coverage Index</title>\n</head>\n' +
+ '<body>\n<h1>Code Coverage Information</h1>\n<ul>' +
+ index.map(function(fname) {
+ return '<li><a href="' +
+ fname.replace(process.cwd() + '/', '').replace('/', '+') + '.html' +
+ '">' + fname + '</a></li>';
+ }).join('\n') + '</ul>\n</body>\n</html>';
+
+ fs.writeFile(cov_dir + '/index.html', out, 'utf8', function(err) {
+ if (err) {
+ throw err;
+ }
+ cb();
+ });
+ }
+ );
+};
+
+
View
499 lib/tap-runner.js
@@ -1,118 +1,429 @@
-module.exports = Runner
+var fs = require("fs"),
+ child_process = require("child_process"),
+ path = require("path"),
+ chain = require("slide").chain,
+ asyncMap = require("slide").asyncMap,
+ TapProducer = require("./tap-producer"),
+ TapConsumer = require("./tap-consumer"),
+ assert = require("./tap-assert"),
+ inherits = require("inherits"),
+ util = require('util'),
+ CovHtml = require('./tap-cov-html');
-var fs = require("fs")
- , child_process = require("child_process")
- , path = require("path")
- , chain = require("slide").chain
- , TapProducer = require("./tap-producer")
- , TapConsumer = require("./tap-consumer")
- , assert = require("./tap-assert")
- , inherits = require("inherits")
-inherits(Runner, TapProducer)
-function Runner (dir, diag, cb) {
- Runner.super.call(this, diag)
+var Runner = module.exports = function(dir, diag, cb) {
+ Runner.super.call(this, diag);
- if (dir) this.run(dir, cb)
+ // An array of full paths to files to obtain coverage
+ this.coverage_files = [];
+ // The source of these files
+ this.coverage_files_source = {};
+ // Where to write coverage information
+ this.coverage_out_dir = './coverage';
+ // Temporary test files bunkerified we'll remove later
+ this.f2delete = [];
+ // Raw coverage stats, as read from JSON files
+ this.raw_cov_stats = [];
+ // Processed coverage information, per file to cover:
+ this.cov_stats = {};
+
+ if (dir) {
+ var files_to_cover = './lib',
+ coverage_out_dir = './coverage';
+ if (process.env.TAP_COV) {
+ dir = dir.filter(function(arg) {
+ if (arg.match(/^--cover=/)) {
+ files_to_cover = arg.split('--cover=')[1];
+ return false;
+ } else if (arg.match(/^--cover-dir=/)) {
+ coverage_out_dir = arg.split('--cover-dir=')[1];
+ return false;
+ }
+ return true;
+ });
+ coverage_out_dir = path.resolve(coverage_out_dir);
+ path.exists(coverage_out_dir, function(exists) {
+ if (!exists) {
+ fs.mkdir(coverage_out_dir, 0755, function(er) {
+ if (er) {
+ throw er;
+ }
+ });
+ }
+ });
+ this.coverage_out_dir = coverage_out_dir;
+ this.getFilesToCover(files_to_cover);
+ }
+ this.run(dir, cb);
+ }
}
-Runner.prototype.run = function () {
- var self = this
- , args = Array.prototype.slice.call(arguments)
- , cb = args.pop() || function (er) {
- if (er) self.emit("error", er)
- self.end()
- }
- if (Array.isArray(args[0])) args = args[0]
- self.runFiles(args, "", cb)
+inherits(Runner, TapProducer);
+
+
+Runner.prototype.run = function() {
+ var self = this,
+ args = Array.prototype.slice.call(arguments),
+ cb = args.pop() || function (er) {
+ if (er) {
+ self.emit("error", er);
+ }
+
+ if (process.env.TAP_COV) {
+ // Cleanup temporary test files with coverage:
+ self.f2delete.forEach(function(f) {
+ fs.unlinkSync(f);
+ });
+ self.getFilesToCoverSource(function(err, data) {
+ if (err) {
+ self.emit('error', err);
+ }
+ self.getPerFileCovInfo(function(err, data) {
+ if (err) {
+ self.emit('error', err);
+ }
+ self.mergeCovStats(function(err, data) {
+ if (err) {
+ self.emit('error', err);
+ }
+ CovHtml(self.cov_stats, self.coverage_out_dir, function() {
+ setTimeout(function() {
+ self.end();
+ }, 500);
+ })
+ });
+ });
+ });
+ } else {
+ self.end();
+ }
+ };
+ if (Array.isArray(args[0])) {
+ args = args[0];
+ }
+ self.runFiles(args, "", cb);
}
Runner.prototype.runDir = function (dir, cb) {
- var self = this
+ var self = this;
fs.readdir(dir, function (er, files) {
if (er) {
- self.write(assert.fail("failed to readdir "+dir,
- { error: er }))
- self.end()
- return
+ self.write(assert.fail("failed to readdir " + dir,
+ { error: er }));
+ self.end();
+ return;
}
- files = files.sort(function (a,b) {return a>b ? 1 : -1})
- files = files.filter(function (f) {return !f.match(/^\./)})
- files = files.map(path.resolve.bind(path, dir))
+ files = files.sort(function(a, b) {
+ return a > b ? 1 : -1;
+ });
+ files = files.filter(function(f) {
+ return !f.match(/^\./);
+ });
+ files = files.map(path.resolve.bind(path, dir));
- self.runFiles(files, path.resolve(dir), cb)
+ self.runFiles(files, path.resolve(dir), cb);
})
}
+
Runner.prototype.runFiles = function (files, dir, cb) {
- var self = this
- chain(files.map(function (f) { return function (cb) {
- var relDir = dir || path.dirname(f)
- , fileName = relDir === "." ? f : f.substr(relDir.length + 1)
-
- self.write(fileName)
- fs.lstat(f, function (er, st) {
- if (er) {
- self.write(assert.fail("failed to stat "+f,
- {error: er}))
- return cb()
- }
+ var self = this;
+ chain(files.map(function(f) {
+ return function (cb) {
+ var relDir = dir || path.dirname(f),
+ fileName = relDir === "." ? f : f.substr(relDir.length + 1);
- var cmd = f
- , args = []
+ self.write(fileName);
+ fs.lstat(f, function(er, st) {
+ if (er) {
+ self.write(assert.fail("failed to stat " + f,
+ {error: er}));
+ return cb();
+ }
- if (path.extname(f) === ".js") {
- cmd = "node"
- args = [fileName]
- } else if (path.extname(f) === ".coffee") {
- cmd = "coffee"
- args = [fileName]
- }
- if (st.isDirectory()) {
- return self.runDir(f, cb)
- }
+ var cmd = f, args = [], env = {};
+
+ if (path.extname(f) === ".js") {
+ cmd = "node";
+ args = [fileName];
+ } else if (path.extname(f) === ".coffee") {
+ cmd = "coffee";
+ args = [fileName];
+ }
+
+ if (st.isDirectory()) {
+ return self.runDir(f, cb);
+ }
+
+ if (process.env.TAP_COV && path.extname(f) === ".js") {
+ var foriginal = fs.readFileSync(f, 'utf8'),
+ fcontents = self.coverHeader() + foriginal + self.coverFooter(),
+ tmp_fname = path.dirname(f) + '/' +
+ path.basename(f, path.extname(f)) +
+ '.with-coverage.' + process.pid + path.extname(f),
+ i;
+
+ fs.writeFileSync(tmp_fname, fcontents, 'utf8');
+ args = [tmp_fname];
+ }
+
+ for (i in process.env) {
+ env[i] = process.env[i];
+ }
+ env.TAP = 1;
+
+ var cp = child_process.spawn(cmd, args, { env: env, cwd: relDir }),
+ out = "",
+ err = "",
+ tc = new TapConsumer(),
+ childTests = [f];
+
+ tc.on("data", function(c) {
+ self.emit("result", c);
+ self.write(c);
+ });
+
+ cp.stdout.pipe(tc);
+ cp.stdout.on("data", function(c) { out += c });
+ cp.stderr.on("data", function(c) { err += c });
+
+ cp.on("exit", function(code) {
+ //childTests.forEach(function (c) { self.write(c) })
+ var res = {
+ name: fileName,
+ ok: !code
+ };
+
+ if (err) {
+ res.stderr = err;
+ if (tc.results.ok && tc.results.tests === 0) {
+ // perhaps a compilation error or something else failed...
+ console.error(err);
+ }
+ }
+ res.command = [cmd].concat(args).map(JSON.stringify).join(" ");
+ self.emit("result", res);
+ self.emit("file", f, res, tc.results);
+ self.write(res);
+ self.write("\n");
+ if (process.env.TAP_COV) {
+ self.f2delete.push(tmp_fname);
+ }
+ cb();
+ });
+ });
+ }
+ }), cb);
+
+ return self;
+}
+
+
+// Get an array of full paths to files we are interested into obtain
+// code coverage.
+Runner.prototype.getFilesToCover = function(files_to_cover) {
+ var self = this;
+ files_to_cover = files_to_cover.split(',').map(function(f) {
+ return path.resolve(f);
+ }).filter(function(f) {
+ return path.existsSync(f);
+ });
+ files_to_cover.forEach(function(f) {
+ if (path.extname(f) === '') {
+ // Is a directory:
+ fs.readdirSync(f).forEach(function(p) {
+ self.coverage_files.push(f + '/' + p);
+ });
+ } else {
+ self.coverage_files.push(f);
+ }
+ });
+};
- var env = {}
- for (var i in process.env) env[i] = process.env[i]
- env.TAP = 1
-
- var cp = child_process.spawn(cmd, args, { env: env, cwd: relDir })
- , out = ""
- , err = ""
- , tc = new TapConsumer
- , childTests = [f]
-
- tc.on("data", function (c) {
- self.emit("result", c)
- self.write(c)
- })
-
- cp.stdout.pipe(tc)
- cp.stdout.on("data", function (c) { out += c })
- cp.stderr.on("data", function (c) { err += c })
-
- cp.on("exit", function (code) {
- //childTests.forEach(function (c) { self.write(c) })
- var res = { name: fileName
- , ok: !code }
+// Prepend to every test file to run. Note tap.test at the very top due it
+// 'plays' with include paths.
+Runner.prototype.coverHeader = function() {
+ return 'var test = require(\'tap\').test,\n' +
+ ' runforcover = require(\'runforcover\'),\n' +
+ ' coverage = runforcover.cover(/.*/g),\n' +
+ ' fs = require(\'fs\'),\n' +
+ ' path = require(\'path\');\n';
+};
+
+// Append at the end of every test file to run. Actually, the stuff which gets
+// the coverage information.
+// Maybe it would be better to move into a separate file template so editing
+// could be easier.
+Runner.prototype.coverFooter = function() {
+ var self = this;
+ // This needs to be a string with proper interpolations:
+ return '' +
+ 'test(\'___coverage\', function(t) {\n' +
+ ' var cov_files = ' + util.inspect(self.coverage_files) + ',\n' +
+ ' cov_dir = \'' + self.coverage_out_dir + '\',\n' +
+ ' test_fn = path.resolve(\'' + process.cwd() + '\',cov_dir) + \'/\' + path.basename(__filename, \'.js\') + \'.json\';\n' +
+ ' function asyncForEach(arr, fn, callback) {\n' +
+ ' if (!arr.length) {\n' +
+ ' return callback();\n' +
+ ' }\n' +
+ ' var completed = 0;\n' +
+ ' arr.forEach(function(i) {\n' +
+ ' fn(i, function (err) {\n' +
+ ' if (err) {\n' +
+ ' callback(err);\n' +
+ ' callback = function () {};\n' +
+ ' }\n' +
+ ' else {\n' +
+ ' completed += 1;\n' +
+ ' if (completed === arr.length) {\n' +
+ ' callback();\n' +
+ ' }\n' +
+ ' }\n' +
+ ' });\n' +
+ ' });\n' +
+ ' };\n' +
+ ' coverage(function(coverageData) {\n' +
+ ' var out_obj = {};\n' +
+ ' asyncForEach(cov_files, function(f, cb) {\n' +
+ ' if (coverageData[f]) {\n' +
+ ' var stats = coverageData[f].stats(),\n' +
+ ' st_obj = {\n' +
+ ' percentage: stats.percentage,\n' +
+ ' missing: stats.missing,\n' +
+ ' seen: stats.seen,\n' +
+ ' lines: stats.lines.map(function(l) {\n' +
+ ' return {\n' +
+ ' number: l.lineno,\n' +
+ ' source: l.source()\n' +
+ ' };\n' +
+ ' })\n' +
+ ' };\n' +
+ ' out_obj[f] = st_obj;\n' +
+ ' cb();\n' +
+ ' } else {\n' +
+ ' cb();\n' +
+ ' }\n' +
+ ' }, function(err) {\n' +
+ ' coverage.release();\n' +
+ ' fs.writeFileSync(test_fn, JSON.stringify(out_obj));\n' +
+ ' t.end();\n' +
+ ' });\n' +
+ ' });\n' +
+ '});\n';
+};
+
+
+Runner.prototype.getFilesToCoverSource = function(cb) {
+ var self = this;
+ asyncMap(
+ self.coverage_files,
+ function(f, cb) {
+ fs.readFile(f, 'utf8', function(err, data) {
+ var lc = 0;
if (err) {
- res.stderr = err
- if (tc.results.ok && tc.results.tests === 0) {
- // perhaps a compilation error or something else failed...
- console.error(err)
+ cb(err);
+ }
+ self.coverage_files_source[f] = data.split('\n').map(function(l) {
+ lc += 1;
+ return {
+ number: lc,
+ source: l
}
+ });
+ cb();
+ });
+ },
+ cb
+ );
+};
+
+Runner.prototype.getPerFileCovInfo = function(cb) {
+ var self = this,
+ cov_path = path.resolve(self.coverage_out_dir);
+
+ fs.readdir(cov_path, function(err, files) {
+ if (err) {
+ self.emit("error", err);
+ }
+ var cov_files = files.filter(function(f) {
+ return path.extname(f) === '.json';
+ });
+ asyncMap(
+ cov_files,
+ function(f, cb) {
+ fs.readFile(cov_path + '/' + f, 'utf8', function(err, data) {
+ if (err) {
+ cb(err);
+ }
+ self.raw_cov_stats.push(JSON.parse(data));
+ cb();
+ });
+ },
+ function(f, cb) {
+ fs.unlink(cov_path + '/' + f, function(err) {
+ if (err) {
+ cb(err);
+ }
+ cb();
+ });
+ },
+ cb
+ );
+ });
+};
+
+Runner.prototype.mergeCovStats = function(cb) {
+ var self = this;
+ self.raw_cov_stats.forEach(function(st) {
+ Object.keys(st).forEach(function(i) {
+ // If this is the first time we reach this file, just add the info:
+ if (!self.cov_stats[i]) {
+ self.cov_stats[i] = {
+ missing: st[i].lines
}
- res.command = [cmd].concat(args).map(JSON.stringify).join(" ")
- self.emit("result", res)
- self.emit("file", f, res, tc.results)
- self.write(res)
- self.write("\n")
- cb()
- })
- })
- }}), cb)
-
- return self
-}
+ } else {
+ // If we already added info for this file before, we need to remove
+ // from self.cov_stats any line not duplicated again (since it has
+ // run on such case)
+ self.cov_stats[i].missing = self.cov_stats[i].missing.filter(
+ function(l) {
+ return (st[i].lines.indexOf(l));
+ });
+ }
+ });
+ });
+
+ // This is due to a bug into
+ // chrisdickinson/node-bunker/blob/feature/add-coverage-interface
+ // which is using array indexes for line numbers instead of the right number
+ Object.keys(self.cov_stats).forEach(function(f) {
+ self.cov_stats[f].missing = self.cov_stats[f].missing.map(function(line) {
+ return {
+ number: line.number,
+ source: line.source
+ };
+ });
+ });
+
+ Object.keys(self.coverage_files_source).forEach(function(f) {
+ if (!self.cov_stats[f]) {
+ self.cov_stats[f] = {
+ missing: self.coverage_files_source[f],
+ percentage: 0
+ }
+ }
+ self.cov_stats[f].lines = self.coverage_files_source[f];
+ self.cov_stats[f].loc = self.coverage_files_source[f].length;
+
+ if (!self.cov_stats[f].percentage) {
+ self.cov_stats[f].percentage =
+ 1 - (self.cov_stats[f].missing.length / self.cov_stats[f].loc);
+ }
+
+ });
+ cb();
+};
+
+
View
1  package.json
@@ -8,6 +8,7 @@
{ "inherits" : "*"
, "yamlish" : "*"
, "slide": "*"
+ , "runforcover": "0.0.1"
}
, "bundledDependencies" :
[ "inherits"
Please sign in to comment.
Something went wrong with that request. Please try again.