#!/usr/bin/env node // Copyright 2011 Itay Neeman // // Licensed under the MIT License // TODO: (function() { var fs = require('fs'); var path = require('path'); var vm = require("vm"); var crypto = require('crypto'); var Module = require('module').Module; var _ = require('underscore'); var _s = require('underscore.string'); var which = require('which').sync; var commander = require('../contrib/commander'); var Table = require('../contrib/cli-table'); var pkg = require('../package.json'); var exists = fs.existsSync || path.existsSync; var patch_console = require('../utils/console'); var cover = require("../index.js"); // Store the initial CWD, to harden us against process.chdir var CWD = path.resolve(process.cwd()); // Get a list of the built-in reporters var reporterOptions = "[" + _.keys(cover.reporters).join(",") + "]"; /* ====== Utilities ====== */ // Delete a directory recursively var rmdirRecursiveSync = function(dirPath) { var files = fs.readdirSync(dirPath); for(var i = 0; i < files.length; i++) { var filePath = path.join(dirPath, files[i]); var file = fs.statSync(filePath); if (file.isDirectory()) { rmdirRecursiveSync(filePath); } else { fs.unlinkSync(filePath); } } fs.rmdirSync(dirPath); }; /* ====== Config/Ignore File Handling ====== */ // Default config var defaultConfigPath = path.join(path.resolve(__dirname), ".coverrc"); var defaultConfig = {}; var removeJSONComments = function(str) { str = str || ''; str = str.replace(/\/\*[\s\S]*(?:\*\/)/g, ''); //everything between "/* */" str = str.replace(/\/\/[^\n\r]*/g, ''); //everything after "//" return str; }; var readIgnoreFile = function(ignorePath, ignoreModules) { var ignore = {}; // Get the full path relative to the CWD var fullIgnorePath = path.resolve(CWD, ignorePath); if (exists(fullIgnorePath)) { // If we found an ignore file, read it in var ignoreContents = fs.readFileSync(fullIgnorePath, "utf-8"); try { // Note that directory fo the ignore path var dir = path.dirname(fullIgnorePath); var ignores = ignoreContents.split("\n"); // For each line (ignoring blank ones), get the full path, // and add it to our ignore list. for(var i = 0; i < ignores.length; i++) { if (ignores[i].trim() === "") { continue; } var filePath = path.resolve(dir, ignores[i].trim()); ignore[path.resolve(dir, filePath)] = true; } } catch (ex) { throw new Error("There was a problem parsing your .coverignore file at: " + ignorePath); } } return ignore; }; var readConfigFile = function(configFile) { var config = {} // Get the full path relative to the CWD configFile = path.resolve(CWD, configFile); if (!exists(configFile)) { configFile = defaultConfigPath; } // Remove any comments from the JSON var configContents = removeJSONComments(fs.readFileSync(path.resolve(configFile), "utf-8")); try { // If there is no content, we'll just add an empty object if (configContents.trim() === "") { configContents = "{}"; } // Parse it and copy in the defaults var rawConfig = JSON.parse(configContents); for(var key in defaultConfig) { config[key] = defaultConfig[key]; } // Override the defaults with specific ones. for(var key in rawConfig) { config[key] = rawConfig[key]; } } catch (ex) { throw new Error("There was a problem parsing your .coverrc file at: " + configFile); } return config; }; var getOptionsAndIgnore = function(cmdline, options) { options = options || {}; // Get configuration var config = {}; if (cmdline.config || ".coverrc") { if (_.isObject(cmdline.config)) { config = cmdline.config; } else { config = readConfigFile(cmdline.config || ".coverrc"); } } // Get ignore var ignore = {}; if (cmdline.ignore || config.ignore || ".coverignore") { if (_.isObject(cmdline.ignore)) { ignore = cmdline.ignore; } else { ignore = readIgnoreFile(cmdline.ignore || config.ignore || ".coverignore"); } } // If we didn't override it on the command line and the // coverrc file says to ingore node_modules, then ignore // it if (options.modules !== true && config.modules === false) { ignore[path.resolve(CWD, "node_modules")] = true; } return { config: config, ignore: ignore } }; defaultConfig = readConfigFile(defaultConfigPath); /* ====== Context and Execution Handling ====== */ // Run a file with coverage enabled var runFile = function(file, options, ignore, debug) { if (!exists(file)) { try { file = which(file); } catch(ex) { console.log("Could not find file: '" + file + "' -- exiting..."); return null; } } else { file = path.resolve(file); } var coverage = cover.cover(null, ignore, debug); process.nextTick(function() { try { // Load up the new argv options = options || []; process.argv = ["node", file].concat(options) // Load the file as the main module Module.runMain(file, null, true) } catch(ex) { console.log(ex.stack); } }); return coverage; }; /* ====== Save/Load Coverage Data ====== */ var saveCoverageData = function(coverageData, configs, noPrecombine) { if (!noPrecombine) { savePrecombinedCoverageData(coverageData, configs); } // Setup the information we're going to save var files = {}; var toSave = { version: pkg.version, files: files }; _.each(coverageData, function(fileData, fileName, lst) { // For each file, we hash the source to get a "version ID" // for it var stats = fileData.stats(); var fileSource = stats.source; var md5 = crypto.createHash('md5'); md5.update(fileSource); var hash = md5.digest('hex'); // We also save the stats and the hash, // which is everything we need in order // to be able to generate reports files[fileName] = { stats: stats, hash: hash } }); // Turn it into JSON and write it out var data = JSON.stringify(toSave); // Get the ID for this data (md5 hash) var dataMd5 = crypto.createHash('md5'); dataMd5.update(data); var dataHash = dataMd5.digest('hex'); // Make the directory var dataDirectory = path.join(CWD, configs.dataDirectory); if (!exists(dataDirectory)) { fs.mkdirSync(dataDirectory, "0755"); } // Write out the file var dataFilename = path.join(dataDirectory, configs.prefix + dataHash); fs.writeFileSync(dataFilename, data); }; var savePrecombinedCoverageData = function(coverageData, configs) { // Setup the information we're going to save var files = {}; var toSave = { version: "0.2.0", files: files }; var coverageStore = require('../coverage_store'); _.each(coverageData, function(fileData, fileName, lst) { var store = coverageStore.getStore(fileName); // For each file, we hash the source to get a "version ID" // for it var fileSource = fileData.source; var md5 = crypto.createHash('md5'); md5.update(fileSource); var hash = md5.digest('hex'); // We also save the stats and the hash, // which is everything we need in order // to be able to generate reports files[fileName] = { nodes: store.nodes, blocks: store.blocks, hash: hash, instrumentor: fileData.instrumentor.objectify() } }); // Turn it into JSON and write it out var data = JSON.stringify(toSave); // Get the ID for this data (md5 hash) var dataMd5 = crypto.createHash('md5'); dataMd5.update(data); var dataHash = dataMd5.digest('hex'); // Make the directory var dataDirectory = path.join(CWD, configs.dataDirectory); if (!exists(dataDirectory)) { fs.mkdirSync(dataDirectory, "0755"); } // Write out the file var dataFilename = path.join(dataDirectory, "precombined_" + configs.prefix + dataHash); fs.writeFileSync(dataFilename, data); }; var loadCoverageData = function(filename, configs) { var dataDirectory = configs.dataDirectory; var dataPrefix = configs.prefix; if (!filename) { // If we weren't given a file, try and read it from the data directory. var filesInDataDirectory = fs.readdirSync(dataDirectory); var matchingFiles = _.filter(filesInDataDirectory, function(file) { return _s.startsWith(file, dataPrefix); }); if (matchingFiles.length > 1) { // If there is more than one file, we'll use the last modified one. var latestTime = 0; for(var i = 0; i < matchingFiles.length; i++) { var fileLatest = fs.statSync(path.join(dataDirectory, matchingFiles[i])).mtime; if (fileLatest > latestTime) { latestTime = fileLatest; filename = matchingFiles[i]; } } } else if (matchingFiles.length === 1) { // If there was exactly one file, we'll use that one filename = matchingFiles[0]; } else if (matchingFiles.length === 0) { // If there was no file, error. console.error("Could not find a coverage data file. Please specify one."); process.exit(1); } // Note the full path to the file filename = path.join(dataDirectory, filename); } // If it doesn't exist, then error out if (!exists(filename)) { console.error("Coverage data file does not exist: " + filename); process.exit(1); } // Read the file var data = fs.readFileSync(filename, "utf-8"); var coverageData = JSON.parse(data); var overall = { filename: 'Overall', missing : 0, seen : 0, total : 0, blocks : { total : 0, seen : 0, missing : 0 } }; // Remake the objects into what the reporters expect _.each(coverageData.files, function(fileData, fileName) { var stats = fileData.stats; fileData.filename = fileName; fileData.stats = function() { return stats; }; fileData.source = stats.source; _.each(['missing', 'seen', 'total'], function(field) { overall[field] += stats[field]; overall.blocks[field] += stats.blocks[field]; }); }); overall.percentage = overall.seen / overall.total; overall.blocks.percentage = overall.blocks.seen / overall.blocks.total; coverageData.overall = {stats: function() { return overall; }}; return coverageData; }; var loadPrecombinedCoverageData = function(configs) { var dataDirectory = configs.dataDirectory; var dataPrefix = configs.prefix; var filesInDataDirectory = fs.readdirSync(dataDirectory); var files = _.filter(filesInDataDirectory, function(file) { return _s.startsWith(file, "precombined_" + dataPrefix); }).map(function(file) { return path.join(dataDirectory, file); }); var datas = []; _.each(files, function(filename) { // If it doesn't exist, then error out if (!exists(filename)) { console.error("Coverage data file does not exist: " + filename); process.exit(1); } // Read the file var data = fs.readFileSync(filename, "utf-8"); datas.push(JSON.parse(data)); fs.unlinkSync(filename); }); var coverageData = cover.load(datas); return coverageData; }; /* ====== Reporters ====== */ var reporterHandlers = { html: function(coverageData, config) { fileData = coverageData.files; var files = _.sortBy(_.keys(fileData), function(file) { return file; }); var allStats = []; var statsByFile = {}; var htmlDirectory = path.join(CWD, config.html.directory); var templateDir = path.resolve(__dirname, "..", "templates"); var resourceDir = path.resolve(__dirname, "..", "resources"); function statsRow(name, stats) { return [ name, Math.floor(stats.percentage * 100) + "%", stats.missing, stats.total, Math.floor(stats.blocks.percentage * 100) + "%", stats.blocks.missing, stats.blocks.total ]; } var htmls = {}; for(var i = 0; i < files.length; i++) { var filename = files[i]; htmls[filename] = cover.reporters.html.format(fileData[filename]); } // For each file, we compile a list of its summmary stats for(var i = 0; i < files.length; i++) { var filename = files[i]; var stats = fileData[filename].stats(); statsByFile[filename] = { percentage: Math.floor(stats.percentage * 100) + "%", missingLines: stats.missing, seenLines: stats.seen, partialLines: _.values(stats.coverage).filter(function(lineinfo) { return lineinfo.partial; }).length, totalLines: stats.total, blockPercentage: Math.floor(stats.blocks.percentage * 100) + "%", missingBlocks: stats.blocks.missing, seenBlocks: stats.blocks.seen, totalBlocks: stats.blocks.total, }; allStats.push(statsRow(path.relative(CWD, filename), stats)); } allStats.push(statsRow('Overall', coverageData.overall.stats())); // Delete the HTML directory if necessary, and then create it if (exists(htmlDirectory)) { rmdirRecursiveSync(htmlDirectory); } fs.mkdirSync(htmlDirectory, "755"); // For each HTML file, use the template to generate an HTML file, // and write it out to disk _.each(htmls, function(html, filename) { var outputPath = path.join(htmlDirectory, filename.replace(/[\/|\:|\\]/g, "_") + ".html"); var htmlTemplateString = fs.readFileSync(path.join(templateDir, "file.html")).toString("utf-8"); var htmlTemplate = _.template(htmlTemplateString); var completeHtml = htmlTemplate({ filename: filename, code: html, stats: statsByFile[filename] }); fs.writeFileSync(outputPath, completeHtml); }); // Compile the index file with the template, and write it out to disk var indexTemplateString = fs.readFileSync(path.join(templateDir, "index.html")).toString("utf-8"); var indexTemplate = _.template(indexTemplateString); var headers = ["Filename", "% Covered", "Missed Lines", "# Lines", "% Blocks", "Missed Blocks", "# Blocks"]; var fileUrls = {}; _.each(files, function(file) { fileUrls[path.relative(CWD, file)] = file.replace(/[\/|\:|\\]/g, "_") + ".html"; }); var indexHtml = indexTemplate({ headers: headers, data: allStats, fileUrls: fileUrls }); fs.writeFileSync(path.join(htmlDirectory, "index.html"), indexHtml); // Copy over the resource files fs.link(path.join(resourceDir, "bootstrap.css"), path.join(htmlDirectory, "bootstrap.css")); fs.link(path.join(resourceDir, "prettify.css"), path.join(htmlDirectory, "prettify.css")); fs.link(path.join(resourceDir, "prettify.js"), path.join(htmlDirectory, "prettify.js")); fs.link(path.join(resourceDir, "jquery.min.js"), path.join(htmlDirectory, "jquery.min.js")); fs.link(path.join(resourceDir, "jquery.tablesorter.min.js"), path.join(htmlDirectory, "jquery.tablesorter.min.js")); }, cli: function(coverageData, config) { fileData = coverageData.files; var files = _.sortBy(_.keys(fileData), function(file) { return file; }); var table = new Table({ head: ["Filename", "% Covered", "Missed Lines", "# Lines", "% Blocks", "Missed Blocks", "# Blocks"], style : { 'padding-left' : 1, 'padding-right ': 1, head: ['cyan'] }, colWidths: [cover.reporters.cli.MAX_FILENAME_LENGTH + 5, 10, 14, 10, 10, 14, 10] }); for(var i = 0; i < files.length; i++) { var filename = files[i]; table.push(cover.reporters.cli.format(fileData[filename])); } table.push(cover.reporters.cli.format(coverageData.overall)); console.log(table.toString()); }, plain: function(coverageData, config) { fileData = coverageData.files; var files = _.sortBy(_.keys(fileData), function(file) { return file; }); for(var i = 0; i < files.length; i++) { var filename = files[i]; console.log(cover.reporters.plain.format(fileData[filename])); } }, json: function(coverageData, config) { fileData = coverageData.files; var files = _.sortBy(_.keys(fileData), function(file) { return file; }); var jsons = []; for(var i = 0; i < files.length; i++) { var filename = files[i]; jsons.push(cover.reporters.json.format(fileData[filename])); } jsons.push(cover.reporters.json.format(coverageData.overall)); console.log(JSON.stringify(jsons)); }, __catch_all: function(coverageData, config, reporter) { } }; /* ====== Commands ====== */ var run = function(file, cmdline, config, ignore, debug) { if (debug) { var debugDirectory = path.join(CWD, config.debugDirectory); if (!exists(debugDirectory)) { fs.mkdirSync(debugDirectory, "0755"); } } // Run the file var coverage = runFile(file, cmdline, ignore, debug ? config.debugDirectory : null); // Setup the on exit listener process.on( "exit", function() { // For debugging purposes, make sure we can print things patch_console.patch(); if (!coverage) { return; } coverage(function(coverageData) { try { saveCoverageData(coverageData, config); } catch(e) { console.log(e.stack); } }); } ); }; var report = function(reporterName, file, config, ignore) { var coverageData = loadCoverageData(file, config); reporterName = reporterName || "cli"; var reporter = ""; if (cover.reporters[reporterName]) { reporter = cover.reporters[reporterName]; } else { try { reporter = require(path.resolve(reporterName)); } catch(ex) { console.log("Could not find reporter: " + reporterName); process.exit(1); } } (reporterHandlers[reporterName] || reporterHandlers.__catch_all)(coverageData, config, reporter); } var combine = function(configFile, ignoreFile) { var files = getOptionsAndIgnore({config: configFile, ignore: ignoreFile}); var config = files.config; var ignore = files.ignore; var coverageData = loadPrecombinedCoverageData(config); saveCoverageData(coverageData, config, true); } var hook = function(configFile, ignoreFile) { var files = getOptionsAndIgnore({config: configFile, ignore: ignoreFile}); var config = files.config; var ignore = files.ignore; var cover = require('../index.js'); coverage = cover.cover(null, ignore, global); // Setup the on exit listener process.on( "exit", function() { // For debugging purposes, make sure we can print things patch_console.patch(); coverage(function(coverageData) { try { saveCoverageData(coverageData, config); } catch(e) { console.log(e.stack); } }); } ); } var hookAndReport = function(reporterName, configFile, ignoreFile) { var files = getOptionsAndIgnore({config: configFile, ignore: ignoreFile}); var config = files.config; var ignore = files.ignore; hook(configFile, ignoreFile); process.on("exit", function() { report(reporterName, null, config, ignore); }); } var hookAndCombine = function(configFile, ignoreFile) { var files = getOptionsAndIgnore({config: configFile, ignore: ignoreFile}); var config = files.config; var ignore = files.ignore; hook(configFile, ignoreFile); process.on("exit", function() { combine(config); }); } var hookAndCombineAndReport = function(reporterName, configFile, ignoreFile) { var files = getOptionsAndIgnore({config: configFile, ignore: ignoreFile}); var config = files.config; var ignore = files.ignore; hook(configFile, ignoreFile); process.on("exit", function() { combine(config); report(reporterName, null, config, ignore); }); } /* ====== Command Line Parsing ====== */ commander .version(pkg.version) .option("-c, --config <config>", "Path to a .coverrc file") .option("-i, --ignore <ignore>", "Path to a .coverignore file") commander.command("run <file>") .description("Run a file and collect code coverage information for it") .option("-d, --debug", "Enable debugging") .option("-m, --modules", "Enable coverage for node_modules") .action(function(file, options) { var configs = getOptionsAndIgnore(this, options); var config = configs.config; var ignore = configs.ignore; var debug = options.debug; // Remove the command itself var cmdline = this.args.slice(1); run(file, cmdline, config, ignore, debug); }); commander.command("report [reporter] [file]") .description("Report on saved coverage data. Specify a coverage file and a reporter: " + reporterOptions) .action(function(reporterName, file) { var configs = getOptionsAndIgnore(this); var config = configs.config; var ignore = configs.ignore; report(reporterName, file, config, ignore); }); commander.command("combine") .description("Combine multiple coverage files (from multiple runs) into a single, reportable file") .action(function(reporterName, file) { var configs = getOptionsAndIgnore(this); var config = configs.config; var ignore = configs.ignore; combine(config, ignore); }); var printHelp = function() { console.log(commander.helpInformation()); } commander.command("*") .action(function() { printHelp(); }); /* ====== Exports ====== */ module.exports = { parse: function(argv) { var cmdline = commander.parse(argv); if (!cmdline.executedCommand) { printHelp(); } return cmdline; }, run: run, report: report, hook: hook, hookAndReport: hookAndReport, combine: combine, hookAndCombine: hookAndCombine, hookAndCombineAndReport: hookAndCombineAndReport }; })(); if (module === require.main) { module.exports.parse(process.argv); }