Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
Download
80668 views
#!/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);
}