Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Merge branch 'cov'

  • Loading branch information...
commit 725d8c849e2de3da7667ef6aa087553b32660e37 2 parents 62c5653 + ace791f
@isaacs authored
View
4 .gitignore
@@ -1 +1,3 @@
-node_modules/*
+node_modules/
+coverage/
+coverage-example/coverage/
View
12 README.md
@@ -44,7 +44,7 @@ For extra special bonus points, you can do something like this:
t.end() // but it must match the plan!
})
-Node-tap is actually a collection of several packages, any of which may be
+Node-tap is actually a collection of several modules, any of which may be
mixed and matched however you please.
If you don't like this test framework, and think you can do much much
@@ -53,8 +53,6 @@ however, at least to output TAP-compliant results when `process.env.TAP`
is set, then the data coming out of your framework will be much more
consumable by machines.
-That matters. Or rather, it will, very soon.
-
You can also use this to build programs that *consume* the TAP data, so
this is very useful for CI systems and such.
@@ -74,5 +72,11 @@ 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.
-More docs coming soon, hopefully.
+## Experimental Code Coverage with runforcover & bunker:
+
+```
+TAP_COV=1 tap ./tests [--cover=./lib,foo.js] [--cover-dir=./coverage]
+```
+This feature is experimental, and will most likely change somewhat
+before being finalized. Feedback welcome.
View
8 bin/tap.js
@@ -12,7 +12,7 @@ if (process.env.TAP || process.env.TAP_DIAG) {
r.on("file", function (file, results, details) {
var s = (details.ok ? "" : "not ") + "ok "+results.name
, n = details.pass + "/" + details.testsTotal
- , dots = new Array(Math.max(1, 40 - s.length - n.length)).join(".")
+ , dots = new Array(Math.max(1, 60 - s.length - n.length)).join(".")
console.log("%s %s %s", s, dots, n)
if (details.ok) {
if (details.skip) {
@@ -29,9 +29,13 @@ if (process.env.TAP || process.env.TAP_DIAG) {
//console.log(r)
var s = "total"
, n = r.results.pass + "/" + r.results.testsTotal
- , dots = new Array(40 - s.length - n.length).join(".")
+ , dots = new Array(60 - s.length - n.length).join(".")
, ok = r.results.ok ? "ok" : "not ok"
console.log("%s %s %s\n\n%s", s, dots, n, ok)
+ if (r.doCoverage) {
+ console.error( "\nCoverage: %s\n"
+ , path.resolve(r.coverageOutDir, "index.html") )
+ }
// process.stdout.flush()
})
}
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
78 lib/tap-cov-html.js
@@ -0,0 +1,78 @@
+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) ? '#fcc' : '#cfc';
+ return '<li id="L' + line.number + '" style="background-color: ' + color +
+ ';">' + line.source.replace(/</g, "&lt;") + '</li>';
+ }).join('\n') +
+ '</ol>\n' +
+ '<h2>Data</h2>\n'+
+ '<pre>' + util.inspect(st, true, Infinity, false).replace(/</g, "&lt;") + '</pre></body>\n</html>';
+
+ fs.writeFile(
+ cov_dir + '/' +
+ f.replace(process.cwd() + '/', '').replace(/\//g, '+') + '.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(/\//g, '+') + '.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
9 lib/tap-harness.js
@@ -88,11 +88,18 @@ Harness.prototype.process = function () {
//console.error("_plan", this._plan, this.constructor.name)
} else {
//console.error("Harness process: no more left. ending")
- this.end()
+ if (this._endNice) {
+ this._endNice()
+ } else {
+ this.end()
+ }
}
}
Harness.prototype.end = function () {
+ if (this._children.length) {
+ return this.process()
+ }
//console.error("harness end", this.constructor.name)
if (this._bailedOut) return
View
437 lib/tap-runner.js
@@ -1,30 +1,108 @@
-module.exports = Runner
-
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")
+ , asyncMap = require("slide").asyncMap
+ , TapProducer = require("./tap-producer.js")
+ , TapConsumer = require("./tap-consumer.js")
+ , assert = require("./tap-assert.js")
, inherits = require("inherits")
+ , util = require("util")
+ , CovHtml = require("./tap-cov-html.js")
+ , doCoverage = process.env.TAP_COV
+ || process.env.npm_package_config_coverage
+ || process.env.npm_config_coverage
-inherits(Runner, TapProducer)
-
-function Runner (dir, diag, cb) {
+var Runner = module.exports = function (dir, diag, cb) {
Runner.super.call(this, diag)
- if (dir) this.run(dir, cb)
+ this.doCoverage = doCoverage
+ // An array of full paths to files to obtain coverage
+ this.coverageFiles = []
+ // The source of these files
+ this.coverageFilesSource = {}
+ // Where to write coverage information
+ this.coverageOutDir = "./coverage"
+ // Temporary test files bunkerified we'll remove later
+ this.f2delete = []
+ // Raw coverage stats, as read from JSON files
+ this.rawCovStats = []
+ // Processed coverage information, per file to cover:
+ this.covStats = {}
+
+ if (dir) {
+ var filesToCover = "./lib"
+ , coverageOutDir = "./coverage"
+
+ if (doCoverage) {
+ dir = dir.filter(function(arg) {
+ if (arg.match(/^--cover=/)) {
+ filesToCover = arg.split("--cover=")[1]
+ return false
+ } else if (arg.match(/^--cover-dir=/)) {
+ coverageOutDir = arg.split("--cover-dir=")[1]
+ return false
+ }
+ return true
+ })
+ coverageOutDir = path.resolve(coverageOutDir)
+ path.exists(coverageOutDir, function(exists) {
+ if (!exists) {
+ fs.mkdir(coverageOutDir, 0755, function(er) {
+ if (er) {
+ throw er
+ }
+ })
+ }
+ })
+ this.coverageOutDir = coverageOutDir
+ this.getFilesToCover(filesToCover)
+ }
+ this.run(dir, cb)
+ }
}
-Runner.prototype.run = function () {
+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)
- self.end()
+ if (er) {
+ self.emit("error", er)
+ }
+
+ if (doCoverage) {
+ // 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.covStats, self.coverageOutDir, function() {
+ self.end()
+ })
+ })
+ })
+ })
+ } else {
+ self.end()
+ }
}
- if (Array.isArray(args[0])) args = args[0]
+ if (Array.isArray(args[0])) {
+ args = args[0]
+ }
self.runFiles(args, "", cb)
}
@@ -32,87 +110,306 @@ Runner.prototype.runDir = function (dir, cb) {
var self = this
fs.readdir(dir, function (er, files) {
if (er) {
- self.write(assert.fail("failed to readdir "+dir,
- { error: er }))
+ 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.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)
})
}
+
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()
- }
+ 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 (doCoverage && path.extname(f) === ".js") {
+ var foriginal = fs.readFileSync(f, "utf8")
+ , fcontents = self.coverHeader() + foriginal + self.coverFooter()
+ , tmpBaseName = path.basename(f, path.extname(f))
+ + ".with-coverage." + process.pid + path.extname(f)
+ , tmpFname = path.resolve(path.dirname(f), tmpBaseName)
+ , i
+
+ fs.writeFileSync(tmpFname, fcontents, "utf8")
+ args = [tmpFname]
+ }
+
+ 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]
+
+ setTimeout(function () {
+ if (!cp._ended) {
+ cp.kill()
+ console.error("killing: " + f + " (timed out)")
+ }
+ }, ((process.env.TAP_TIMEOUT || 30) * 1000))
+
+ 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) {
+ cp._ended = true
+ //childTests.forEach(function (c) { self.write(c) })
+ var res = { name: path.dirname(f).replace(process.cwd() + "/", "")
+ + "/" + 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 (doCoverage) {
+ self.f2delete.push(tmpFname)
+ }
+ cb()
+ })
+ })
+ }
+ }), cb)
- var env = {}
- for (var i in process.env) env[i] = process.env[i]
- env.TAP = 1
+ return self
+}
+
+
+// Get an array of full paths to files we are interested into obtain
+// code coverage.
+Runner.prototype.getFilesToCover = function(filesToCover) {
+ var self = this
+ filesToCover = filesToCover.split(",").map(function(f) {
+ return path.resolve(f)
+ }).filter(function(f) {
+ return path.existsSync(f)
+ })
+
+ function recursive(f) {
+ if (path.extname(f) === "") {
+ // Is a directory:
+ fs.readdirSync(f).forEach(function(p) {
+ recursive(f + "/" + p)
+ })
+ } else {
+ self.coverageFiles.push(f)
+ }
+ }
+ filesToCover.forEach(function(f) {
+ recursive(f)
+ })
+}
+
+// Prepend to every test file to run. Note tap.test at the very top due it
+// "plays" with include paths.
+Runner.prototype.coverHeader = function() {
+ // semi here since we're injecting it before the first line,
+ // and don't want to mess up line numbers in the test files.
+ return "var ___TAP_COVERAGE = require("
+ + JSON.stringify(require.resolve("runforcover"))
+ + ").cover(/.*/g);"
+}
+
+// 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 [ ""
+ , "var ___TAP = require(" + JSON.stringify(require.resolve("./main.js")) + ")"
+ , "if (typeof ___TAP._plan === 'number') ___TAP._plan ++"
+ , "___TAP.test(" + JSON.stringify("___coverage") + ", function(t) {"
+ , " var covFiles = " + JSON.stringify(self.coverageFiles)
+ , " , covDir = " + JSON.stringify(self.coverageOutDir)
+ , " , path = require('path')"
+ , " , fs = require('fs')"
+ , " , testFnBase = path.basename(__filename, '.js') + '.json'"
+ , " , testFn = path.resolve(covDir, testFnBase)"
+ , ""
+ , " function asyncForEach(arr, fn, callback) {"
+ , " if (!arr.length) {"
+ , " return callback()"
+ , " }"
+ , " var completed = 0"
+ , " arr.forEach(function(i) {"
+ , " fn(i, function (err) {"
+ , " if (err) {"
+ , " callback(err)"
+ , " callback = function () {}"
+ , " } else {"
+ , " completed += 1"
+ , " if (completed === arr.length) {"
+ , " callback()"
+ , " }"
+ , " }"
+ , " })"
+ , " })"
+ , " }"
+ , ""
+ , " ___TAP_COVERAGE(function(coverageData) {"
+ , " var outObj = {}"
+ , " asyncForEach(covFiles, function(f, cb) {"
+ , " if (coverageData[f]) {"
+ , " var stats = coverageData[f].stats()"
+ , " , stObj = stats"
+ , " stObj.lines = stats.lines.map(function (l) {"
+ , " return { number: l.lineno, source: l.source() }"
+ , " })"
+ , " outObj[f] = stObj"
+ , " }"
+ , " cb()"
+ , " }, function(err) {"
+ , " ___TAP_COVERAGE.release()"
+ , " fs.writeFileSync(testFn, JSON.stringify(outObj))"
+ , " t.end()"
+ , " })"
+ , " })"
+ , "})" ].join("\n")
+}
- 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)
+Runner.prototype.getFilesToCoverSource = function(cb) {
+ var self = this
+ asyncMap(self.coverageFiles, function(f, cb) {
+ fs.readFile(f, "utf8", function(err, data) {
+ var lc = 0
+ if (err) {
+ cb(err)
+ }
+ self.coverageFilesSource[f] = data.split("\n").map(function(l) {
+ lc += 1
+ return { number: lc, source: l }
})
+ cb()
+ })
+ }, cb)
+}
- cp.stdout.pipe(tc)
- cp.stdout.on("data", function (c) { out += c })
- cp.stderr.on("data", function (c) { err += c })
+Runner.prototype.getPerFileCovInfo = function(cb) {
+ var self = this
+ , covPath = path.resolve(self.coverageOutDir)
- cp.on("exit", function (code) {
- //childTests.forEach(function (c) { self.write(c) })
- var res = { name: fileName
- , ok: !code }
+ fs.readdir(covPath, function(err, files) {
+ if (err) {
+ self.emit("error", err)
+ }
+ var covFiles = files.filter(function(f) {
+ return path.extname(f) === ".json"
+ })
+ asyncMap(covFiles, function(f, cb) {
+ fs.readFile(path.resolve(covPath, f), "utf8", function(err, data) {
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)
}
- 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")
+ self.rawCovStats.push(JSON.parse(data))
cb()
})
+ }, function(f, cb) {
+ fs.unlink(path.resolve(covPath, f), cb)
+ }, cb)
+ })
+}
+
+Runner.prototype.mergeCovStats = function(cb) {
+ var self = this
+ self.rawCovStats.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.covStats[i]) {
+ self.covStats[i] = {
+ missing: st[i].lines
+ }
+ } else {
+ // If we already added info for this file before, we need to remove
+ // from self.covStats any line not duplicated again (since it has
+ // run on such case)
+ self.covStats[i].missing = self.covStats[i].missing.filter(
+ function(l) {
+ return (st[i].lines.indexOf(l))
+ })
+ }
})
- }}), cb)
+ })
- return self
+ // 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.covStats).forEach(function(f) {
+ self.covStats[f].missing = self.covStats[f].missing.map(function(line) {
+ return { number: line.number, source: line.source }
+ })
+ })
+
+ Object.keys(self.coverageFilesSource).forEach(function(f) {
+ if (!self.covStats[f]) {
+ self.covStats[f] = { missing: self.coverageFilesSource[f]
+ , percentage: 0
+ }
+ }
+ self.covStats[f].lines = self.coverageFilesSource[f]
+ self.covStats[f].loc = self.coverageFilesSource[f].length
+
+ if (!self.covStats[f].percentage) {
+ self.covStats[f].percentage =
+ 1 - (self.covStats[f].missing.length / self.covStats[f].loc)
+ }
+
+ })
+ cb()
}
+
+
View
1  lib/tap-test.js
@@ -85,6 +85,7 @@ function assertParasite (fn) { return function _testAssert () {
if (this._bailedOut) return
var res = fn.apply(assert, arguments)
this.result(res)
+ return res
}}
// a few tweaks on the EE emit function, because
View
1  package.json
@@ -8,6 +8,7 @@
{ "inherits" : "*"
, "yamlish" : "*"
, "slide": "*"
+ , "runforcover": "0.0.1"
}
, "bundledDependencies" :
[ "inherits"
View
23 test/nested-test.js
@@ -0,0 +1,23 @@
+var tap = require("../"),
+ test = tap.test,
+ util = require('util');
+
+test("parent", function (t) {
+ // TODO: Make grandchildren tests count?
+ t.plan(3);
+ t.ok(true, 'p test');
+ t.test("subtest", function (t) {
+ t.ok(true, 'ch test');
+ t.test('nested subtest', function(t) {
+ t.ok(true, 'grch test');
+ t.end();
+ });
+ t.end();
+ });
+ t.test('another subtest', function(t) {
+ t.ok(true, 'ch test 2');
+ t.end();
+ });
+ t.end();
+})
+
Please sign in to comment.
Something went wrong with that request. Please try again.