Coverage Report

KindCoveredTotalPercentage
lines147.4883950029.49%
conditions97.0319926037.31%
expressions303.887674640.73%
functions302.2572465646.07%

Key

SteamShovel uses the stack depth to determine how directly code was tested. Code which was not tested directly is not considered highly in the coverage output.

lib/index.js

KindCoveredTotalPercentage
lines0.3435965.72%
conditions000%
expressions1.03079156.87%
functions000%
"use strict";

// Export the reporter so that mocha can require it directly by name
module.exports = require("./reporter");

// Return the components
module.exports.instrument	= require("./instrumentor");
module.exports.process		= require("./process");
module.exports.reporter		= require("./reporter");
module.exports.recorder		= require("./instrumentor-record");

lib/reporter.js

KindCoveredTotalPercentage
lines0.05764460.12%
conditions0130%
expressions0.11529610.18%
functions0530%
"use strict";

exports = module.exports = SteamShovel;

var stats = require("./stats"),
	columnify = require("columnify");

global.__steamshovel = true;

function SteamShovel(runner) {
	console.log("Steam Shovel");

	var failures = [];

	runner.suite.beforeAll(function() {
		console.log("Commencing coverage test!");
	});

	runner.on('pass', function(test){
		process.stdout.write(".");
	});

	runner.on('fail', function(test, err){
		process.stdout.write("x");
		failures.push([test, err]);
	});

	runner.on('end', function(){

		console.log("\n\n");

		if (failures.length) {
			console.error("Test failures occurred while processing!");

			return failures.forEach(function(failure) {
				var test = failure[0], err = failure[1];

				console.error("• %s\n\t%s\n%s\n\n", test.title, err, err.stack);
			});
		}

		var coverageData = global.__instrumentor_map__ || {},
			basicStats = stats.basic(coverageData),
			arrayTransformedStats = Object.keys(basicStats).map(function(key) {
				return {
					"kind": key,
					"covered": basicStats[key].covered,
					"total": basicStats[key].total,
					"percentage": basicStats[key].percentage
				};
			});

		var outputTable = columnify(arrayTransformedStats, {
			columnSplitter: " | "
		});

		console.log(outputTable);

		require("./outputs/html")(coverageData, function(result) {
			console.log("Report written to disk.");
		});
	});

}

lib/stats/index.js

KindCoveredTotalPercentage
lines0.0060440.15%
conditions000%
expressions0.0120840.3%
functions000%
"use strict";

module.exports = {
	"basic": require("./basic")
};

lib/stats/basic.js

KindCoveredTotalPercentage
lines61.003811752.14%
conditions42218.18%
expressions101.007616063.12%
functions101.0031615366.01%
"use strict";

// Basic statistics for generating lines, conditions, expressions
// and functions covered.
//
// All these functions return a map, containing three values:
//
// eg.
// { covered: 15, total: 25, percengage: 0.6 }

var util = require("./utils");

module.exports = function generateStatistics(inputData, key) {
	var self = module.exports;

	if (!inputData)
		throw new Error("Input was undefined.");

	return {
		"lines":		self.lines(inputData, key),
		"conditions":	self.conditions(inputData, key),
		"expressions":	self.expressions(inputData, key),
		"functions":	self.functions(inputData, key)
	};
};

module.exports.lines = function(inputData, key) {
	var covered = 0, total = 0,
		getValue = util.calculateValue(inputData),
		uniqueLines = {};

	total =
		Object.keys(inputData)
			.filter(function(key) {
				return key.indexOf("source_") === 0;
			})
			.filter(function(index) {
				if (!key) return true;
				return inputData[index].tag === key;
			})
			.reduce(function(acc, cur) {
				return acc + util.sloc(inputData[cur].source);
			}, 0);

	Object.keys(inputData)
		.filter(function(index) {
			return index.indexOf("id_" + (key || "")) === 0;
		})
		.forEach(function(key) {
			var item	= inputData[key],
				id		= key.split("_")[1],
				start	= item.loc.start.line,
				results	= item.results;

			if (results.length)
				uniqueLines[id + "_" + start] =
					Math.max(
						getValue(item),
						uniqueLines[id + "_" + start] || 0);
		});

	covered =
		Object.keys(uniqueLines)
			.reduce(function(acc, cur) {
				return acc + uniqueLines[cur];
			}, 0);

	return {
		covered: covered,
		total: total,
		percentage: util.percentage(covered, total)
	};
};

module.exports.conditions = function(inputData, key) {
	var covered = 0, total = 0,
		getValue = util.calculateValue(inputData),
		instruments =
			util.getInstruments(inputData, key)
				.filter(util.isOfType([
						"IfStatement",
						"LogicalExpression",
						"ConditionalExpression",
					]));

	total = instruments.length;
	covered =
		instruments
			.filter(util.hasResults)
			.reduce(function(count, item) {
				return count + getValue(item);
			}, 0);

	return {
		covered: covered,
		total: total,
		percentage: util.percentage(covered, total)
	};
};

module.exports.expressions = function(inputData, key) {
	var covered = 0, total = 0,
		getValue = util.calculateValue(inputData),
		instruments = util.getInstruments(inputData, key);

	total = instruments.length;
	covered =
		instruments
			.filter(util.hasResults)
			.reduce(function(count, item) {
				return count + getValue(item);
			}, 0);

	return {
		covered: covered,
		total: total,
		percentage: util.percentage(covered, total)
	};
};

module.exports.functions = function(inputData, key) {
	var covered = 0, total = 0,
		getValue = util.calculateValue(inputData),
		instruments =
			util.getInstruments(inputData, key)
				.filter(util.isOfType([
						"FunctionDeclaration",
						"FunctionExpression",
					]));

	total = instruments.length;

	covered =
		instruments
			.filter(util.hasResults)
			.reduce(function(count, item) {
				return count + getValue(item);
			}, 0);

	return {
		covered: covered,
		total: total,
		percentage: util.percentage(covered, total)
	};
};

lib/stats/utils.js

KindCoveredTotalPercentage
lines16.001328818.18%
conditions115320.75%
expressions36.0026514325.17%
functions36.0011913227.27%
"use strict";

var util = module.exports = {};

util.sloc = function(sourceInput) {
	sourceInput = String(sourceInput || "");

	return (
		sourceInput.split(/\r?\n/ig)
			.filter(util.lineIsWhitespace)
			.reduce(function(acc, cur) {

				cur =
					cur	.split(/\/\//)
						.shift()
						.replace(/\/\*.*?\*\//g, "")
						.trim();

				if (~cur.indexOf("/*"))
					acc.inComment = true,
					cur = cur.split(/\/\*/).shift().trim();

				if (~cur.indexOf("*/"))
					acc.inComment = false,
					cur = cur.split("*/").slice(1).join("*/").trim();

				if (!cur.length)
					return acc;

				if (!acc.inComment)
					acc.count ++;

				return acc;

			}, { count: 0, inComment: false})
			.count
	);
}

util.lineIsWhitespace = function(line) {
	line = String(line || "");

	return !line.match(/^\s*$/);
}


util.shallowestCall = function shallowestCall(inputData) {
	if (inputData.__shallowestCall !== undefined)
		return inputData.__shallowestCall;

	return inputData.__shallowestCall = (
		util.getInstruments(inputData)
			.filter(util.hasResults)
			.reduce(function(prev, cur) {
				return cur.results.reduce(function(prev, cur) {
					return cur.depth < prev ? cur.depth : prev;
				}, prev);
			}, Infinity));
};

util.getInstruments = function getInstruments(inputData, scope) {
	return (
		Object.keys(inputData)
			.filter(function(key) {
				return key.indexOf("id_" + (scope || "")) === 0;
			})
			.map(function(key) {
				return inputData[key];
			}));
};

util.isOfType = function isOfType(types) {
	types = types instanceof Array ? types : [types];

	return function(item) {
		return types.reduce(function(acc, cur) {
			return acc || ~item.stack.indexOf(cur);
		}, false);
	};
};

util.hasResults = function hasResults(item) {
	return (item && item.results && item.results.length);
};

util.calculateValue = function calculateValue(inputData) {
	var shallowMark = util.shallowestCall(inputData),
		graceThreshold = 5,
		inverseDampingFactor = 1.25;

	return function(item) {
		return (
			Math.max.apply(Math,
				item.results.map(function(result) {
					// Calculate inverse logarithm
					var relativeDepth = (result.depth - shallowMark) + 1;
						relativeDepth = relativeDepth - graceThreshold;
						relativeDepth = relativeDepth <= 0 ? 1 : relativeDepth;

					return 1 / (
						Math.pow(inverseDampingFactor, relativeDepth) /
							inverseDampingFactor);
				})));
	}
}

util.getMinimumDepth = function getMinimumDepth(item) {
	return Math.min.apply(Math, item.results.map(function(result) {
		return result.depth;
	}));
};

util.percentage = function percentage(a, b) {
	return (+((a/b)*10000)|0) / 100;
};

lib/instrumentor.js

KindCoveredTotalPercentage
lines69.9153413053.78%
conditions82.031999685.44%
expressions165.4402320182.3%
functions165.2096418589.3%
"use strict";

var crypto		= require("crypto"),
	esprima		= require("esprima"),
	escodegen	= require("escodegen"),
	estraverse	= require("estraverse"),

	// Maps for instrumenting
	noReplaceMap		= require("./instrumentor-config").noReplaceMap,
	allowedReplacements	= require("./instrumentor-config").allowedReplacements;

/*
	Public: Instrument a string of JS code using the SteamShovel instrumentor.
	This function executes synchronously.

	data			-	The raw JavaScript code to instrument
	filename		-	The filename of the code in question, for inclusion in
						the instrument map
	incorporateMap	-	Whether to include the map (defaults to true, but useful
						for testing output without the additional noise of the
						map.)

	Returns a string containing the instrumented code.

	Examples

		code = instrumentCode("function(a) { return a; }", "myfile.js");

*/

module.exports = function instrumentCode(data, filename, incorporateMap) {

	filename = filename || "implicit-filename";
	incorporateMap = incorporateMap === false ? false : true;

	var ast = esprima.parse(data, {loc: true, range: true, raw: true}),
		filetag = crypto.createHash("md5").update(filename).digest("hex"),

		// State and storage
		id = 0,
		code = null,
		sourceMap = {};

	// Process AST
	estraverse.replace(ast, {

		// Enter is where we mark nodes as noReplace
		// which prevents bugs around weird edge cases, which I'll probably
		// discover as time goes on.
		//
		// We also record a stack path to be provided to the instrumentor
		// that lets us know our place in the AST when the instrument recorder
		// is called.

		enter: preprocessNode,

		// Leave is where we replace the actual nodes.

		leave: function (node) {

			if (node.noReplace)
				return;

			// If we're allowed to replace the node,
			// replace it with a Sequence Expression.

			if (~allowedReplacements.indexOf(node.type))
				return (
					id++,
					sourceMap["id_" + filetag + "_" + id] = {
						"loc": node.loc,
						"range": node.range,
						"results": [],
						"stack": node.stackPath,
						"type": node.type
					},
					sequenceExpression(
						"id_" + filetag + "_" + id,
						node
					));
		}
	});

	code = escodegen.generate(ast);

	if (incorporateMap)
		code = prependInstrumentorMap(data, filename, code, sourceMap);

	return code;
};

/*
	Public: Preprocess a node to save the AST stack/path into the node, and
	to mark whether its children should be replaced or not.

	data			-	The AST node as represented by Esprima.

	Returns null.

	Examples

		preprocessNode(astNode);

*/

var preprocessNode = module.exports.preprocessNode =
	function preprocessNode(node) {

	if (!node.stackPath)
		node.stackPath = [node.type];

	// Now mark a path to the node.
	Object.keys(node).forEach(function(nodekey) {
		var prop = node[nodekey];

		function saveStack(prop) {
			// This property most likely isn't a node.
			if (!prop || typeof prop !== "object" || !prop.type) return;

			prop.stackPath = node.stackPath.concat(prop.type);
		}

		if (Array.isArray(prop))
			prop.forEach(saveStack);

		saveStack(prop);
	});

	var nodeRule = noReplaceMap[node.type];

	if (!nodeRule) return;

	// Convert the rule to an array so we can handle it using
	// the same logic.
	//
	// Strings and arrays just wholesale exclude the child nodes
	// of the current node where they match.

	if (nodeRule instanceof String)
		nodeRule = [nodeRule];

	if (nodeRule instanceof Array) {
		nodeRule.forEach(function(property) {
			if (!node[property]) return;

			if (node[property] instanceof Array)
				return node[property].forEach(function(item) {
					item.noReplace = true;
				});

			node[property].noReplace = true;
		});
	}

	// Whereas this more verbose object style allows
	// exclusion based on subproperty matches.

	if (nodeRule instanceof Object) {
		Object.keys(nodeRule).forEach(function(property) {
			if (!node[property]) return;

			var exclude =
				Object
					.keys(nodeRule[property])
					.reduce(function(prev, cur) {
						if (!prev) return prev;
						return (
							node[property][cur] ===
								nodeRule[property][cur]);
					}, true);

			if (exclude) node[property].noReplace = true;
		});
	}
};

/*
	Public: Generates a replacement SequenceExpression for a given node,
	which includes a call to an instrument function as its first argument.

	id				-	A unique ID string to mark the call
	data			-	The AST node as represented by Esprima.

	Returns an object representing a replacement node.

	Examples

		sequenceExpression(id, astNode);

*/

var sequenceExpression = module.exports.sequenceExpression =
	function sequenceExpression(id, node) {
	return {
		"type": "SequenceExpression",
		"expressions": [
			{
				"type": "CallExpression",
				"callee": {
					"type": "Identifier",
					"name": "instrumentor_record"
				},
				"arguments": [
					{
						"type": "Literal",
						"value": id
					}
				]
			},
			node
		]
	}
};

/*
	Public: Given a string representation of JavaScript sourcecode, a filename,
	an AST representing the rewritten, instrumented code, and a map containing
	metainformation about the instrument probes themselves, this function
	prepends a string/source representation of the instrument probes, as well
	as the sourcecode of the function that performs the instrumentation itself.

	source			-	The original sourcecode of the file.
	filename		-	A unique filename representing the path to the
						uninstrumented code on disk.
	code			-	The instrumented AST to be generated to source
	sourceMap		-	A map of information about each instrument probe and
						containing the sourcecode of the file.

	Returns the instrumented JavaScript sourcecode to be executed or written
	to disk.

	Examples

		prependInstrumentorMap(source, filename, code, sourceMap);

*/

var prependInstrumentorMap = module.exports.prependInstrumentorMap =
	function prependInstrumentorMap(source, filename, code, sourceMap) {

	var filetag = crypto.createHash("md5").update(filename).digest("hex"),
		sourceObject = { "source": String(source), "tag": filetag },
		originalSource = JSON.stringify(sourceObject),
		sourceKey = JSON.stringify("source_" + filename);

	var initialiser =
		"if (typeof __instrumentor_map__ === 'undefined') {" +
			"__instrumentor_map__ = {};" +
		"}\n" +
		"if (!__instrumentor_map__[" + sourceKey + "]) {" +
			"__instrumentor_map__[" + sourceKey + "] = " + originalSource + ";"+
		"}\n";

	Object.keys(sourceMap).forEach(function(key) {
		initialiser +=
			"if (!__instrumentor_map__." + key + ") {"	+
				" __instrumentor_map__." + key + " = "	+
							JSON.stringify(sourceMap[key])	+
			";}\n";
	});

	initialiser += "\n" + require("./instrumentor-record").toString() + "\n";

	return initialiser + code;
};

lib/instrumentor-config.js

KindCoveredTotalPercentage
lines0.04533380.11%
conditions000%
expressions0.04835160.3%
functions000%
"use strict";

var allowedReplacements = [
	"AssignmentExpression",
	"ArrayExpression",
	"ArrayPattern",
	"ArrowFunctionExpression",
	"BinaryExpression",
	"CallExpression",
	"ConditionalExpression",
	"FunctionExpression",
	"Identifier",
	"LogicalExpression",
	"MemberExpression",
	"NewExpression",
	"ObjectExpression",
	"ObjectPattern",
	"UnaryExpression",
	"UpdateExpression"
];

var noReplaceMap = {

	// If we don't do this, our instrument code removes the context
	// from member expression function calls and returns the value
	// without the context required by some 'methods'.
	//
	// Without this, calls like array.indexOf() would break.
	"CallExpression": {
		"callee": {
			"type": "MemberExpression"
		}
	},

	// We can't put SequenceExpressions on the left side of
	// AssignmentExpressions.
	//
	// (e.g. (abc, def) = value;)

	"AssignmentExpression": ["left"],

	// Nor can we replace the id component of VariableDeclarators.
	// (e.g. var [id] = value)

	"VariableDeclarator": ["id"],

	// The components of MemberExpressions should not be touched.
	// (e.g. (instrument, abc).(instrument, def) from `abc.def`.)

	"MemberExpression": ["object", "property"],

	// The id component of Functions should not be touched.
	// (e.g. function (instrument, abc)() {} from `function abc() {}`.)

	// The parameters of FunctionExpressions should not be touched.
	// (e.g. function abc((instrument,a)) {} from `function abc(a) {}`.)

	"FunctionExpression": ["id", "params"],
	"FunctionDeclaration": ["id", "params"],

	// The properties of Objects should not be touched.
	// (e.g. {(instrument,a)): b} from `{a:b}`.)

	"Property": ["key"],

	// The parameter of a catch clause should not be touched.
	// (e.g. catch (instrument,a) { ... } from `catch (a) { ... }`.)

	"CatchClause": ["param"],

	// The argument of UpdateExpressions should not be touched.
	// (e.g. (instrument,a)++ from `a++`.)

	"UpdateExpression": ["argument"]
};

module.exports = {
	noReplaceMap: noReplaceMap,
	allowedReplacements: allowedReplacements
};

lib/process.js

KindCoveredTotalPercentage
lines0.11529710.16%
conditions0760%
expressions0.230581460.15%
functions0.043231330.03%
"use strict";

var fs = require("fs"),
	path = require("path"),
	mkdirp = require("mkdirp"),
	instrument = require("./instrumentor"),
	EventEmitter = require("events").EventEmitter;

/*
	Public: Instrument a file or folder using the SteamShovel instrumentor.

	inFile			-	The filesystem location of the input resource
	outFile			-	The filesystem destination for the instrumented resource
	emitter			-	An optional pre-defined event emitter to use when
						emitting status events.

	Returns

		emitter		-	An event emitter from which information about the
						instrumentation progress is dispatched.

	Examples

		instrumentor("myfile.js", "myfile-instrumented.js");

*/

module.exports = function(inFile, outFile, emitter) {

	if (!inFile)
		throw new Error("You must specify an input file/directory.");

	if (!outFile)
		throw new Error("You must specify an output file/directory.");

	emitter =
		emitter && emitter instanceof EventEmitter ? emitter :
			new EventEmitter();

	fs.stat(inFile, function(err, stats) {
		if (err) return emitter.emit("error", err);

		if (stats.isDirectory())
			return module.exports.processDir(inFile, outFile, emitter);

		module.exports.processFile(inFile, outFile, emitter);
	});

	return emitter;
};

/*
	Public: Recursively instrument a folder/directory using the SteamShovel
	instrumentor.

	inDir			-	The filesystem location of the input resource
	outDir			-	The filesystem destination for the instrumented resource
	emitter			-	An optional pre-defined event emitter to use when
						emitting status events.

	Returns

		emitter		-	An event emitter from which information about the
						instrumentation progress is dispatched.

	Examples

		instrumentor("./lib", "./lib-cov");

*/

module.exports.processDir = function processDir(dir, out, emitter) {

	emitter =
		emitter && emitter instanceof EventEmitter ? emitter :
			new EventEmitter();

	emitter.emit("processdir", dir, out);

	try { !fs.statSync(out) }
	catch (e) {
		emitter.emit("mkdir", out);
		mkdirp.mkdirp(out);
	}

	fs.readdir(dir, function(err, dirContents) {
		if (err) return emitter.emit("error", err);

		emitter.emit("readdir", dir);

		dirContents.forEach(function(file) {

			var filePath = path.join(dir, file),
				outPath = path.join(out, file);

			module.exports(filePath, outPath, emitter);
		});
	});

	return emitter;
};

/*
	Public: Instrument a single file using the SteamShovel instrumentor.

	This function will ignore files that the instrumentor cannot parse, files
	which do not have a `.js` file extension, and files which contain

	inFile			-	The filesystem location of the input resource
	outFile			-	The filesystem destination for the instrumented resource
	emitter			-	An optional pre-defined event emitter to use when
						emitting status events.

	Returns

		emitter		-	An event emitter from which information about the
						instrumentation progress is dispatched.

	Examples

		instrumentor("./lib/myfile.js", "./lib-cov/myfile.js");

*/

module.exports.processFile = function processFile(file, out, emitter) {

	emitter =
		emitter && emitter instanceof EventEmitter ? emitter :
			new EventEmitter();

	if (!file.match(/\.js$/i))
		return	emitter.emit("nojs", file),
				fs.createReadStream(file).pipe(fs.createWriteStream(out));

	fs.readFile(file, function(err, data) {
		if (err) return emitter.emit("error", err);

		emitter.emit("readfile", file);

		var code;
			data = String(data);

		if (~data.indexOf("steamShovel:" + "ignore")) {
			emitter.emit("ignore", file);

		} else {
			try {
				code = instrument(data, file);

			} catch (err) {
				emitter.emit("instrumenterror", err, file);
			}
		}

		fs.writeFile(out, code || data, function(err) {
			if (err) return emitter.emit("error", err);

			emitter.emit("writefile", file);
		});
	});

	return emitter;
};