0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

IstanbulJS - レポート改善 - ソースコード修正

Last updated at Posted at 2019-01-09

🚨 react-scripts@2.xを使ってTypeScript環境を構築する場合は、課題 - react-scripts@2.xを用いたTypeScriptのカバレッジレポート に記載の手順にて環境構築を行うこと。

概要

IstanbulJS - レポート改善案で列挙した奇妙な点を解決するため、IstanbulJSのソースコードを修正する。TypeScript用に記載しているが、JavaScriptにも対応する。

screencapture-takuyahara-github-io-share-20190104_IstanbulJS_Coverage_Issue-10_SolutionA-typescript_v1-coverage-2_Function-0_Normal-ClassMethod-tsx-html-2019-01-17-05_37_10.png

前提

TypeScriptを使う場合は、予め [React + TypeScript] カバレッジレポートの取得方法 を実施しておくこと。

ルール

本修正で行うハイライトのルールを以下のように定義する。

ステートメント

  • 末尾の ; はステートメントではないため、ハイライトしない。
  • ステートメントの後に続く空白文字やコメントなど、プログラムとして意味の無い箇所はハイライトしない。
  • 複数行にまたがるステートメントは、全ての行をハイライトする。

ブランチ

if

  • else if 節の if キーワード以降を、複数行にまたがって全てハイライトする。

switch

  • case 節、 default 節の範囲は : までとし、後に続く空白文字やコメントなど、プログラムとして意味の無い箇所はハイライトしない。

関数

記法により、定義部の範囲を以下の通りとする。

Function Declaration

function foo(): void をハイライトする。

function foo(): void {

}

Function Expression

無名関数の場合
function(): void をハイライトする。

const foo = function(): void {

}

名前付き関数の場合
function foo(): void をハイライトする。

const foo = function foo(): void {

}

Arrow Function

括弧がある場合
(bar): void をハイライトする。

const foo = (bar): void => {

}

括弧がない場合
bar をハイライトする。

const foo = bar => {

}

Class Method

アクセス修飾子がある場合
foo(): void をハイライトする。

public foo(): void {

}

アクセス修飾子がない場合
foo(): void をハイライトする。

foo(): void {

}

修正ファイル

visitor.js

目的

関数の定義部を、上述のルールに沿うよう修正する。

パッケージ

📦 istanbul-lib-instrument@1.10.2

コード

node_modules/istanbul-lib-instrument/dist/visitor.js
'use strict';

Object.defineProperty(exports, "__esModule", {
    value: true
});

var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();

var _sourceCoverage = require('./source-coverage');

var _constants = require('./constants');

var _crypto = require('crypto');

var _babelTemplate = require('babel-template');

var _babelTemplate2 = _interopRequireDefault(_babelTemplate);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

// istanbul ignore comment pattern
var COMMENT_RE = /^\s*istanbul\s+ignore\s+(if|else|next)(?=\W|$)/;
// istanbul ignore file pattern
var COMMENT_FILE_RE = /^\s*istanbul\s+ignore\s+(file)(?=\W|$)/;
// source map URL pattern
var SOURCE_MAP_RE = /[#@]\s*sourceMappingURL=(.*)\s*$/m;

// generate a variable name from hashing the supplied file path
function genVar(filename) {
    var hash = (0, _crypto.createHash)(_constants.SHA);
    hash.update(filename);
    return 'cov_' + parseInt(hash.digest('hex').substr(0, 12), 16).toString(36);
}

// VisitState holds the state of the visitor, provides helper functions
// and is the `this` for the individual coverage visitors.

var VisitState = function () {
    function VisitState(types, sourceFilePath, inputSourceMap) {
        _classCallCheck(this, VisitState);

        this.varName = genVar(sourceFilePath);
        this.attrs = {};
        this.nextIgnore = null;
        this.cov = new _sourceCoverage.SourceCoverage(sourceFilePath);

        if (typeof inputSourceMap !== "undefined") {
            this.cov.inputSourceMap(inputSourceMap);
        }
        this.types = types;
        this.sourceMappingURL = null;
    }

    // should we ignore the node? Yes, if specifically ignoring
    // or if the node is generated.


    _createClass(VisitState, [{
        key: 'shouldIgnore',
        value: function shouldIgnore(path) {
            return this.nextIgnore || !path.node.loc;
        }

        // extract the ignore comment hint (next|if|else) or null

    }, {
        key: 'hintFor',
        value: function hintFor(node) {
            var hint = null;
            if (node.leadingComments) {
                node.leadingComments.forEach(function (c) {
                    var v = (c.value || /* istanbul ignore next: paranoid check */"").trim();
                    var groups = v.match(COMMENT_RE);
                    if (groups) {
                        hint = groups[1];
                    }
                });
            }
            return hint;
        }

        // extract a source map URL from comments and keep track of it

    }, {
        key: 'maybeAssignSourceMapURL',
        value: function maybeAssignSourceMapURL(node) {
            var that = this;
            var extractURL = function extractURL(comments) {
                if (!comments) {
                    return;
                }
                comments.forEach(function (c) {
                    var v = (c.value || /* istanbul ignore next: paranoid check */"").trim();
                    var groups = v.match(SOURCE_MAP_RE);
                    if (groups) {
                        that.sourceMappingURL = groups[1];
                    }
                });
            };
            extractURL(node.leadingComments);
            extractURL(node.trailingComments);
        }

        // for these expressions the statement counter needs to be hoisted, so
        // function name inference can be preserved

    }, {
        key: 'counterNeedsHoisting',
        value: function counterNeedsHoisting(path) {
            return path.isFunctionExpression() || path.isArrowFunctionExpression() || path.isClassExpression();
        }

        // all the generic stuff that needs to be done on enter for every node

    }, {
        key: 'onEnter',
        value: function onEnter(path) {
            var n = path.node;

            this.maybeAssignSourceMapURL(n);

            // if already ignoring, nothing more to do
            if (this.nextIgnore !== null) {
                return;
            }
            // check hint to see if ignore should be turned on
            var hint = this.hintFor(n);
            if (hint === 'next') {
                this.nextIgnore = n;
                return;
            }
            // else check custom node attribute set by a prior visitor
            if (this.getAttr(path.node, 'skip-all') !== null) {
                this.nextIgnore = n;
            }
        }

        // all the generic stuff on exit of a node,
        // including reseting ignores and custom node attrs

    }, {
        key: 'onExit',
        value: function onExit(path) {
            // restore ignore status, if needed
            if (path.node === this.nextIgnore) {
                this.nextIgnore = null;
            }
            // nuke all attributes for the node
            delete path.node.__cov__;
        }

        // set a node attribute for the supplied node

    }, {
        key: 'setAttr',
        value: function setAttr(node, name, value) {
            node.__cov__ = node.__cov__ || {};
            node.__cov__[name] = value;
        }

        // retrieve a node attribute for the supplied node or null

    }, {
        key: 'getAttr',
        value: function getAttr(node, name) {
            var c = node.__cov__;
            if (!c) {
                return null;
            }
            return c[name];
        }

        //

    }, {
        key: 'increase',
        value: function increase(type, id, index) {
            var T = this.types;
            var wrap = index !== null
            // If `index` present, turn `x` into `x[index]`.
            ? function (x) {
                return T.memberExpression(x, T.numericLiteral(index), true);
            } : function (x) {
                return x;
            };
            return T.updateExpression('++', wrap(T.memberExpression(T.memberExpression(T.identifier(this.varName), T.identifier(type)), T.numericLiteral(id), true)));
        }
    }, {
        key: 'insertCounter',
        value: function insertCounter(path, increment) {
            var T = this.types;
            if (path.isBlockStatement()) {
                path.node.body.unshift(T.expressionStatement(increment));
            } else if (path.isStatement()) {
                path.insertBefore(T.expressionStatement(increment));
            } else if (this.counterNeedsHoisting(path) && T.isVariableDeclarator(path.parentPath)) {
                // make an attempt to hoist the statement counter, so that
                // function names are maintained.
                var parent = path.parentPath.parentPath;
                if (parent && T.isExportNamedDeclaration(parent.parentPath)) {
                    parent.parentPath.insertBefore(T.expressionStatement(increment));
                } else if (parent && (T.isProgram(parent.parentPath) || T.isBlockStatement(parent.parentPath))) {
                    parent.insertBefore(T.expressionStatement(increment));
                } else {
                    path.replaceWith(T.sequenceExpression([increment, path.node]));
                }
            } else /* istanbul ignore else: not expected */if (path.isExpression()) {
                    path.replaceWith(T.sequenceExpression([increment, path.node]));
                } else {
                    console.error('Unable to insert counter for node type:', path.node.type);
                }
        }
    }, {
        key: 'insertStatementCounter',
        value: function insertStatementCounter(path) {
            /* istanbul ignore if: paranoid check */
            if (!(path.node && path.node.loc)) {
                return;
            }
            if (path.getSource().slice(-1) === ';') {
                path.node.loc.end.column--;
            }
            var index = this.cov.newStatement(path.node.loc);
            var increment = this.increase('s', index, null);
            this.insertCounter(path, increment);
        }
    }, {
        key: 'insertFunctionCounter',
        value: function insertFunctionCounter(path) {
            var T = this.types;
            /* istanbul ignore if: paranoid check */
            if (!(path.node && path.node.loc)) {
                return;
            }
            var n = path.node;

            var dloc = null;
            var name = undefined;
            var source = path.getSource();
            var getDeclLoc = function getDeclLoc(isParameterParenthesized) {
                var offsetCol = null,
                    offsetLine = null,
                    lastParam = null;
                if (n.params.length > 0) {
                    lastParam = n.params[n.params.length - 1];
                    offsetCol = lastParam.end - n.start + (isParameterParenthesized ? 1 : 0);
                } else {
                    offsetCol = source.indexOf(")") + 1;
                }
                offsetLine = source.substr(0, offsetCol).split(/(?:\r?\n)|\r/).length - 1;
                return {
                    start: n.loc.start,
                    end: {
                        line: n.loc.start.line + offsetLine,
                        column: n.loc.start.column + offsetCol
                    }
                };
            };
            // get location for declaration
            switch (n.type) {
                case "FunctionDeclaration":
                    /* istanbul ignore else: paranoid check */
                    dloc = getDeclLoc(true);
                    name = n.id ? n.id.name : n.name;
                    break;
                case "FunctionExpression":
                    dloc = getDeclLoc(true);
                    name = n.id ? n.id.name : n.name;
                    break;
                case "ClassMethod":
                    dloc = getDeclLoc(true);
                    name = n.key ? n.key.name : n.name;
                    break;
                case "ArrowFunctionExpression":
                    var isParameterParenthesized = source.substr(0, 1) === "(";
                    dloc = getDeclLoc(isParameterParenthesized);

                    var isReturnStatement = n.body.body.length === 1 && n.body.body[0].type === "ReturnStatement";
                    var returnStatement = isReturnStatement ? n.body.body[0] : undefined;
                    var isReturnStatementParenthesized = returnStatement && returnStatement.argument.extra && returnStatement.argument.extra.parenthesized === true;
                    if (isReturnStatementParenthesized) {
                        var lines = source.split(/(?:\r?\n)|\r/);
                        var offsetStartCol = n.body.body[0].argument.extra.parenStart - n.start;
                        var offsetStartLine = source.substr(0, offsetStartCol).split(/(?:\r?\n)|\r/).length - 1;
                        var offsetEndLine = lines.length - 1;
                        var offsetEndCol = lines[offsetEndLine].lastIndexOf(")") + 1;
                        n.body.loc = {
                            start: {
                                line: dloc.start.line + offsetStartLine,
                                column: offsetStartLine > 0 ? offsetStartCol : dloc.start.column + offsetStartCol
                            },
                            end: {
                                line: dloc.start.line + offsetEndLine,
                                column: offsetEndLine > 0 ? offsetEndCol : dloc.start.column + offsetEndCol
                            }
                        };
                    }
                    break;
            }
            if (!dloc) {
                dloc = {
                    start: n.loc.start,
                    end: { line: n.loc.start.line, column: n.loc.start.column + 1 }
                };
                name = n.id ? n.id.name : n.name;
            }

            var index = this.cov.newFunction(name, dloc, n.body.loc);
            var increment = this.increase('f', index, null);
            var body = path.get('body');
            /* istanbul ignore else: not expected */
            if (body.isBlockStatement()) {
                body.node.body.unshift(T.expressionStatement(increment));
            } else {
                console.error('Unable to process function body node type:', n.type);
            }
        }
    }, {
        key: 'getBranchIncrement',
        value: function getBranchIncrement(branchName, loc) {
            var index = this.cov.addBranchPath(branchName, loc);
            return this.increase('b', branchName, index);
        }
    }, {
        key: 'insertBranchCounter',
        value: function insertBranchCounter(path, branchName, loc) {
            var increment = this.getBranchIncrement(branchName, loc || path.node.loc);
            this.insertCounter(path, increment);
        }
    }, {
        key: 'findLeaves',
        value: function findLeaves(node, accumulator, parent, property) {
            if (!node) {
                return;
            }
            if (node.type === "LogicalExpression") {
                var hint = this.hintFor(node);
                if (hint !== 'next') {
                    this.findLeaves(node.left, accumulator, node, 'left');
                    this.findLeaves(node.right, accumulator, node, 'right');
                }
            } else {
                accumulator.push({
                    node: node,
                    parent: parent,
                    property: property
                });
            }
        }
    }]);

    return VisitState;
}();

// generic function that takes a set of visitor methods and
// returns a visitor object with `enter` and `exit` properties,
// such that:
//
// * standard entry processing is done
// * the supplied visitors are called only when ignore is not in effect
//   This relieves them from worrying about ignore states and generated nodes.
// * standard exit processing is done
//


function entries() {
    var enter = Array.prototype.slice.call(arguments);
    // the enter function
    var wrappedEntry = function wrappedEntry(path, node) {
        this.onEnter(path);
        if (this.shouldIgnore(path)) {
            return;
        }
        var that = this;
        enter.forEach(function (e) {
            e.call(that, path, node);
        });
    };
    var exit = function exit(path, node) {
        this.onExit(path, node);
    };
    return {
        enter: wrappedEntry,
        exit: exit
    };
}

function coverStatement(path) {
    this.insertStatementCounter(path);
}

/* istanbul ignore next: no node.js support */
function coverAssignmentPattern(path) {
    var n = path.node;
    var b = this.cov.newBranch('default-arg', n.loc);
    this.insertBranchCounter(path.get('right'), b);
}

function coverFunction(path) {
    this.insertFunctionCounter(path);
}

function coverVariableDeclarator(path) {
    this.insertStatementCounter(path.get('init'));
}

function skipInit(path) {
    if (path.node.init) {
        this.setAttr(path.node.init, 'skip-all', true);
    }
}

function makeBlock(path) {
    var T = this.types;
    if (!path.node) {
        path.replaceWith(T.blockStatement([]));
    }
    if (!path.isBlockStatement()) {
        path.replaceWith(T.blockStatement([path.node]));
        path.node.loc = path.node.body[0].loc;
    }
}

function blockProp(prop) {
    return function (path) {
        makeBlock.call(this, path.get(prop));
    };
}

function makeParenthesizedExpression(path) {
    var T = this.types;
    if (path.node) {
        path.replaceWith(T.parenthesizedExpression(path.node));
    }
}

function parenthesizedExpressionProp(prop) {
    return function (path) {
        makeParenthesizedExpression.call(this, path.get(prop));
    };
}

function convertArrowExpression(path) {
    var n = path.node;
    var T = this.types;
    if (!T.isBlockStatement(n.body)) {
        var bloc = n.body.loc;
        if (n.expression === true) {
            n.expression = false;
        }
        n.body = T.blockStatement([T.returnStatement(n.body)]);
        // restore body location
        n.body.loc = bloc;
        // set up the location for the return statement so it gets
        // instrumented
        n.body.body[0].loc = bloc;
    }
}

function coverIfBranches(path) {
    var n = path.node,
        hint = this.hintFor(n),
        ignoreIf = hint === 'if',
        ignoreElse = hint === 'else',
        branch = this.cov.newBranch('if', n.loc);

    if (ignoreIf) {
        this.setAttr(n.consequent, 'skip-all', true);
    } else {
        this.insertBranchCounter(path.get('consequent'), branch, n.loc);
    }
    if (ignoreElse) {
        this.setAttr(n.alternate, 'skip-all', true);
    } else {
        this.insertBranchCounter(path.get('alternate'), branch, n.loc);
    }
}

function createSwitchBranch(path) {
    var b = this.cov.newBranch('switch', path.node.loc);
    this.setAttr(path.node, 'branchName', b);
}

function coverSwitchCase(path) {
    var T = this.types;
    var b = this.getAttr(path.parentPath.node, 'branchName');
    /* istanbul ignore if: paranoid check */
    if (b === null) {
        throw new Error('Unable to get switch branch name');
    }
    var increment = this.getBranchIncrement(b, path.node.loc);
    path.node.consequent.unshift(T.expressionStatement(increment));
}

function coverTernary(path) {
    var n = path.node,
        branch = this.cov.newBranch('cond-expr', path.node.loc),
        cHint = this.hintFor(n.consequent),
        aHint = this.hintFor(n.alternate);

    if (cHint !== 'next') {
        this.insertBranchCounter(path.get('consequent'), branch);
    }
    if (aHint !== 'next') {
        this.insertBranchCounter(path.get('alternate'), branch);
    }
}

function coverLogicalExpression(path) {
    var T = this.types;
    if (path.parentPath.node.type === "LogicalExpression") {
        return; // already processed
    }
    var leaves = [];
    this.findLeaves(path.node, leaves);
    var b = this.cov.newBranch("binary-expr", path.node.loc);
    for (var i = 0; i < leaves.length; i += 1) {
        var leaf = leaves[i];
        var hint = this.hintFor(leaf.node);
        if (hint === 'next') {
            continue;
        }
        var increment = this.getBranchIncrement(b, leaf.node.loc);
        if (!increment) {
            continue;
        }
        leaf.parent[leaf.property] = T.sequenceExpression([increment, leaf.node]);
    }
}

var codeVisitor = {
    ArrowFunctionExpression: entries(convertArrowExpression, coverFunction),
    AssignmentPattern: entries(coverAssignmentPattern),
    BlockStatement: entries(), // ignore processing only
    ClassMethod: entries(coverFunction),
    ClassDeclaration: entries(parenthesizedExpressionProp('superClass')),
    ExpressionStatement: entries(coverStatement),
    BreakStatement: entries(coverStatement),
    ContinueStatement: entries(coverStatement),
    DebuggerStatement: entries(coverStatement),
    ReturnStatement: entries(coverStatement),
    ThrowStatement: entries(coverStatement),
    TryStatement: entries(coverStatement),
    VariableDeclaration: entries(), // ignore processing only
    VariableDeclarator: entries(coverVariableDeclarator),
    IfStatement: entries(blockProp('consequent'), blockProp('alternate'), coverStatement, coverIfBranches),
    ForStatement: entries(blockProp('body'), skipInit, coverStatement),
    ForInStatement: entries(blockProp('body'), skipInit, coverStatement),
    ForOfStatement: entries(blockProp('body'), skipInit, coverStatement),
    WhileStatement: entries(blockProp('body'), coverStatement),
    DoWhileStatement: entries(blockProp('body'), coverStatement),
    SwitchStatement: entries(createSwitchBranch, coverStatement),
    SwitchCase: entries(coverSwitchCase),
    WithStatement: entries(blockProp('body'), coverStatement),
    FunctionDeclaration: entries(coverFunction),
    FunctionExpression: entries(coverFunction),
    LabeledStatement: entries(coverStatement),
    ConditionalExpression: entries(coverTernary),
    LogicalExpression: entries(coverLogicalExpression)
};
// the template to insert at the top of the program.
var coverageTemplate = (0, _babelTemplate2.default)('\n    var COVERAGE_VAR = (function () {\n        var path = PATH,\n            hash = HASH,\n            Function = (function(){}).constructor,\n            global = (new Function(\'return this\'))(),\n            gcv = GLOBAL_COVERAGE_VAR,\n            coverageData = INITIAL,\n            coverage = global[gcv] || (global[gcv] = {});\n        if (coverage[path] && coverage[path].hash === hash) {\n            return coverage[path];\n        }\n        coverageData.hash = hash;\n        return coverage[path] = coverageData;\n    })();\n');
// the rewire plugin (and potentially other babel middleware)
// may cause files to be instrumented twice, see:
// https://github.com/istanbuljs/babel-plugin-istanbul/issues/94
// we should only instrument code for coverage the first time
// it's run through istanbul-lib-instrument.
function alreadyInstrumented(path, visitState) {
    return path.scope.hasBinding(visitState.varName);
}
function shouldIgnoreFile(programNode) {
    return programNode.parent && programNode.parent.comments.some(function (c) {
        return COMMENT_FILE_RE.test(c.value);
    });
}
/**
 * programVisitor is a `babel` adaptor for instrumentation.
 * It returns an object with two methods `enter` and `exit`.
 * These should be assigned to or called from `Program` entry and exit functions
 * in a babel visitor.
 * These functions do not make assumptions about the state set by Babel and thus
 * can be used in a context other than a Babel plugin.
 *
 * The exit function returns an object that currently has the following keys:
 *
 * `fileCoverage` - the file coverage object created for the source file.
 * `sourceMappingURL` - any source mapping URL found when processing the file.
 *
 * @param {Object} types - an instance of babel-types
 * @param {string} sourceFilePath - the path to source file
 * @param {Object} opts - additional options
 * @param {string} [opts.coverageVariable=__coverage__] the global coverage variable name.
 * @param {object} [opts.inputSourceMap=undefined] the input source map, that maps the uninstrumented code back to the
 * original code.
 */
function programVisitor(types) {
    var sourceFilePath = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'unknown.js';
    var opts = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : { coverageVariable: '__coverage__', inputSourceMap: undefined };

    var T = types;
    var visitState = new VisitState(types, sourceFilePath, opts.inputSourceMap);
    return {
        enter: function enter(path) {
            if (shouldIgnoreFile(path.find(function (p) {
                return p.isProgram();
            }))) {
                return;
            }
            if (alreadyInstrumented(path, visitState)) {
                return;
            }
            path.traverse(codeVisitor, visitState);
        },
        exit: function exit(path) {
            if (alreadyInstrumented(path, visitState)) {
                return;
            }
            visitState.cov.freeze();
            var coverageData = visitState.cov.toJSON();
            if (shouldIgnoreFile(path.find(function (p) {
                return p.isProgram();
            }))) {
                return { fileCoverage: coverageData, sourceMappingURL: visitState.sourceMappingURL };
            }
            coverageData[_constants.MAGIC_KEY] = _constants.MAGIC_VALUE;
            var hash = (0, _crypto.createHash)(_constants.SHA).update(JSON.stringify(coverageData)).digest('hex');
            var coverageNode = T.valueToNode(coverageData);
            delete coverageData[_constants.MAGIC_KEY];
            var cv = coverageTemplate({
                GLOBAL_COVERAGE_VAR: T.stringLiteral(opts.coverageVariable),
                COVERAGE_VAR: T.identifier(visitState.varName),
                PATH: T.stringLiteral(sourceFilePath),
                INITIAL: coverageNode,
                HASH: T.stringLiteral(hash)
            });
            cv._blockHoist = 5;
            path.node.body.unshift(cv);
            return {
                fileCoverage: coverageData,
                sourceMappingURL: visitState.sourceMappingURL
            };
        }
    };
}

exports.default = programVisitor;

annotator.js

目的

ハイライトのアルゴリズムを、上述のルールに沿うよう修正する。

パッケージ

📦 istanbul-reports@2.0.1

コード

🚨 TypeScriptを使わない場合のパスは node_modules/istanbul-reports/lib/html/annotator.js である。

node_modules/nyc/node_modules/istanbul-reports/lib/html/annotator.js
/*
 Copyright 2012-2015, Yahoo Inc.
 Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
 */
"use strict";

var InsertionText = require('./insertion-text'),
    lt = '\u0001',
    gt = '\u0002',
    RE_LT = /</g,
    RE_GT = />/g,
    RE_AMP = /&/g,
    RE_lt = /\u0001/g,
    RE_gt = /\u0002/g;

function title(str) {
    return ' title="' + str + '" ';
}

function customEscape(text) {
    text = String(text);
    return text.replace(RE_AMP, '&amp;')
        .replace(RE_LT, '&lt;')
        .replace(RE_GT, '&gt;')
        .replace(RE_lt, '<')
        .replace(RE_gt, '>');
}

function annotateLines(fileCoverage, structuredText) {
    var lineStats = fileCoverage.getLineCoverage();
    if (!lineStats) {
        return;
    }
    Object.keys(lineStats).forEach(function (lineNumber) {
        var count = lineStats[lineNumber];
        if (structuredText[lineNumber]) {
            structuredText[lineNumber].covered = count > 0 ? 'yes' : 'no';
            structuredText[lineNumber].hits = count;
        }
    });
}

function annotateStatements(fileCoverage, structuredText) {
    var statementStats = fileCoverage.s,
        statementMeta = fileCoverage.statementMap;
    var statements = [];
    Object.keys(statementStats).forEach(function (stName) {
        var count = statementStats[stName],
            meta = statementMeta[stName],
            type = count > 0 ? 'yes' : 'no',
            startLine = meta.start.line,
            startCol = meta.start.column,
            endFirstCol = meta.end.column,
            endLastCol = endFirstCol,
            endLine = meta.end.line,
            openSpan = lt + 'span class="' 
                + (meta.skip ? 'cstat-skip' : 'cstat-no') 
                + ' start-line-' + startLine 
                + ' start-column-' + startCol 
                + '"' + title('statement not covered') + gt,
            closeSpan = lt + '/span' + gt,
            i,
            text,
            lines = [];

        if (type === 'no' && structuredText[startLine]) {
            if (endLine !== startLine) {
                endFirstCol = structuredText[startLine].text.originalLength();
            }
            text = structuredText[startLine].text;
            lines.push({
                insertionText: text,
                args: [
                    startCol,
                    openSpan,
                    startCol < endLastCol ? endFirstCol : text.originalLength(),
                    closeSpan
                ]
            });
            for (i = startLine + 1; i < endLine; i++) {
                text = structuredText[i].text;
                lines.push({
                    insertionText: text,
                    args: [
                        text.startPos,
                        openSpan,
                        text.originalLength(),
                        closeSpan
                    ]
                });
            }
            if (endLine !== startLine) {
                text = structuredText[endLine].text;
                lines.push({
                    insertionText: text,
                    args: [
                        text.startPos,
                        openSpan,
                        endLastCol,
                        closeSpan
                    ]
                });
            }
            statements.push({
                startPos: meta.start,
                lines: lines
            });
        }
    });
    // reverse sort
    statements.sort(function (b, a) {
        return a.startPos.line === b.startPos.line ? a.startPos.column - b.startPos.column : a.startPos.line - b.startPos.line;
    });
    statements.map(function (statement) {
        statement.lines.map(function (line) {
            var insertionText = line.insertionText;
            var args = line.args;
            insertionText.wrap.apply(insertionText, args);
        })
    });
}

function annotateFunctions(fileCoverage, structuredText) {
    var fnStats = fileCoverage.f,
        fnMeta = fileCoverage.fnMap;

    if (!fnStats) {
        return;
    }
    Object.keys(fnStats).forEach(function (fName) {
        var count = fnStats[fName],
            meta = fnMeta[fName],
            type = count > 0 ? 'yes' : 'no',
            startCol = meta.decl.start.column,
            endCol = meta.decl.end.column,
            startLine = meta.decl.start.line,
            endLine = meta.decl.end.line,
            openSpan = lt + 'span class="' 
                + (meta.skip ? 'fstat-skip' : 'fstat-no') 
                + ' start-line-' + startLine 
                + ' start-column-' + startCol + '"' 
                + title('function not covered') + gt,
            closeSpan = lt + '/span' + gt,
            i,
            text,
            lines = [];

        if (type === 'no' && structuredText[startLine]) {
            text = structuredText[startLine].text;
            lines.push({
                insertionText: text,
                args: [
                    startCol,
                    openSpan,
                    endLine === startLine ? endCol : text.originalLength(),
                    closeSpan
                ]
            });
            for (i = startLine + 1; i < endLine; i++) {
                text = structuredText[i].text;
                lines.push({
                    insertionText: text,
                    args: [
                        text.startPos,
                        openSpan,
                        text.originalLength(),
                        closeSpan
                    ]
                });
            }
            if (endLine !== startLine) {
                text = structuredText[endLine].text;
                lines.push({
                    insertionText: text,
                    args: [
                        text.startPos,
                        openSpan,
                        endCol,
                        closeSpan
                    ]
                });
            }
            lines.map(function (line) {
                var insertionText = line.insertionText;
                var args = line.args;
                insertionText.wrap.apply(insertionText, args);
            });
        }
    });
}

function annotateBranches(fileCoverage, structuredText) {
    var branchStats = fileCoverage.b,
        branchMeta = fileCoverage.branchMap;
    var branches = [];
    if (!branchStats) {
        return;
    }

    Object.keys(branchStats).forEach(function (branchName) {
        var branchArray = branchStats[branchName],
            sumCount = branchArray.reduce(function (p, n) {
                return p + n;
            }, 0),
            metaArray = branchMeta[branchName],
            i,
            j,
            match,
            lines,
            offset,
            mergedClause,
            count,
            meta,
            type,
            startCol,
            endCol,
            startLine,
            endLine,
            openSpan,
            closeSpan,
            text,
            lines = [];

        // only highlight if partial branches are missing or if there is a
        // single uncovered branch.
        if (sumCount > 0 || (sumCount === 0 && branchArray.length === 1)) {
            for (i = 0; i < branchArray.length && i < metaArray.locations.length; i += 1) {
                j = 1;
                mergedClause = "";
                count = branchArray[i];
                meta = metaArray.locations[i];
                type = count > 0 ? 'yes' : 'no';
                startCol = meta.start.column;
                endCol = meta.end.column;
                startLine = meta.start.line;
                endLine = meta.end.line;
                openSpan = lt + 'span class="branch-' + i + ' ' 
                    + (meta.skip ? 'cbranch-skip' : 'cbranch-no') 
                    + ' start-line-' + startLine 
                    + ' start-column-' + startCol + '"' 
                    + title('branch not covered') + gt;
                closeSpan = lt + '/span' + gt;

                if (count === 0 && structuredText[startLine]) { //skip branches taken
                    if (branchMeta[branchName].type === 'if') {
                    // 'if' is a special case
                    // since the else branch might not be visible, being non-existent
                        text = structuredText[metaArray.loc.start.line].text;
                        text.insertAt(metaArray.loc.start.column, lt + 'span class="' +
                            (meta.skip ? 'skip-if-branch' : 'missing-if-branch') + '"' +
                            title((i === 0 ? 'if' : 'else') + ' path not taken') + gt +
                            (i === 0 ? 'I' : 'E') + lt + '/span' + gt, true, false);
                    } else if (branchMeta[branchName].type === 'switch') {
                        text = structuredText[startLine].text;
                        offset = text.offsets.reduce(function (accumulator, currentValue) {
                            return accumulator + currentValue.len;
                        }, 0);
                        lines = text.toString().substr(startCol + offset);
                        while((match = lines.match(/^\s*(?:case\s+[^\[\('"`:]*[\[\(]?\s*['"`]?(?:\s*:|.*?[\)\]]\s*:|.*?[^\\]['"`]\s*:)|default\s*:)/)) === null) {
                            mergedClause += text.toString();
                            text.wrap(text.startPos,
                                openSpan,
                                text.originalLength(),
                                closeSpan);
                            text = structuredText[startLine + j].text;
                            lines += text.toString();
                            j++;
                        }
                        if (j === 1) {
                            // 'case' or 'default' clause is not splitted
                            text.wrap(startCol,
                                openSpan,
                                startCol + match[0].length,
                                closeSpan);
                        } else {
                            // 'case' or 'default' clause is splitted
                            text.wrap(text.startPos,
                                openSpan,
                                startCol + match[0].length - mergedClause.length,
                                closeSpan);
                        }
                    } else {
                        text = structuredText[startLine].text;
                        if (endLine !== startLine) {
                            endCol = text.originalLength();
                        }
                        text.wrap(startCol,
                            openSpan,
                            startCol < endCol ? endCol : text.originalLength(),
                            closeSpan);
                    }
                }
            }
        }
    });
}


function annotateSourceCode(fileCoverage, sourceStore) {
    var codeArray,
        lineCoverageArray;
    try {
        var sourceText = sourceStore.getSource(fileCoverage.path),
            code = sourceText.split(/(?:\r?\n)|\r/),
            count = 0,
            structured = code.map(function (str) {
                count += 1;
                return {
                    line: count,
                    covered: 'neutral',
                    hits: 0,
                    text: new InsertionText(str, true)
                };
            });
        structured.unshift({line: 0, covered: null, text: new InsertionText("")});
        annotateLines(fileCoverage, structured);
        //note: order is important, since statements typically result in spanning the whole line and doing branches late
        //causes mismatched tags
        annotateBranches(fileCoverage, structured);
        annotateFunctions(fileCoverage, structured);
        annotateStatements(fileCoverage, structured);
        structured.shift();

        codeArray = structured.map(function (item) {
            return customEscape(item.text.toString()) || '&nbsp;';
        });

        lineCoverageArray = structured.map(function (item) {
            return {
                covered: item.covered,
                hits: item.hits > 0 ? item.hits + 'x' : '&nbsp;'
            };
        });

        return {
            annotatedCode: codeArray,
            lineCoverage: lineCoverageArray,
            maxLines: structured.length
        };
    } catch (ex) {
        codeArray = [ ex.message ];
        lineCoverageArray = [ { covered: 'no', hits: 0 } ];
        String(ex.stack || '').split(/\r?\n/).forEach(function (line) {
            codeArray.push(line);
            lineCoverageArray.push({ covered: 'no', hits: 0 });
        });
        return {
            annotatedCode: codeArray,
            lineCoverage: lineCoverageArray,
            maxLines: codeArray.length
        };
    }
}

module.exports = {
    annotateSourceCode: annotateSourceCode
};

block-navigation.js

目的

複数行にまたがったステートメントや節をひとまとめにしてフォーカスさせる。

パッケージ

📦 istanbul-reports@2.0.1

コード

🚨 istanbul-reports@1.xには存在しない。
🚨 TypeScriptを使わない場合のパスは node_modules/istanbul-reports/lib/html/assets/block-navigation.js である。

node_modules/nyc/node_modules/istanbul-reports/lib/html/assets/block-navigation.js
var jumpToCode = (function init () {
  // Classes of code we would like to highlight
  var missingCoverageClasses = [ '.cbranch-no', '.cstat-no', '.fstat-no' ];

  // Selecter that finds elements on the page to which we can jump
  var selector = missingCoverageClasses.join(', ');

  // The NodeList of matching elements
  var missingCoverageElements = document.querySelectorAll(selector);

  // Group of NodeList which has same class
  var groups = {};
  for (var i = 0, l = missingCoverageElements.length; i < l; i++) {
    var node = missingCoverageElements[i];
    var className = node.className;
    if (groups[className] === undefined) {
      groups[className] = [];
    }
    groups[className].push(node);
  }

  // Convert NodeList to Array
  var missingCoverageGroups = Object.keys(groups).map(function(className) {
    return groups[className];
  });

  var currentIndex;

  function toggleClass(index) {
    if (currentIndex === undefined) {
      currentIndex = 0;
    }
    missingCoverageGroups[currentIndex].map(function(node) {
      return node.classList.remove('highlighted');
    });
    missingCoverageGroups[index].map(function(node) {
      return node.classList.add('highlighted');
    });
  }

  function makeCurrent(index) {
    toggleClass(index);
    currentIndex = index;
    missingCoverageGroups[index][0]
      .scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
  }

  function goToPrevious() {
    var nextIndex = 0;
    if (typeof currentIndex !== 'number' || currentIndex === 0) {
      nextIndex = missingCoverageGroups.length - 1;
    } else if (missingCoverageGroups.length > 1) {
      nextIndex = currentIndex - 1;
    }

    makeCurrent(nextIndex);
  }

  function goToNext() {
    var nextIndex = 0;

    if (typeof currentIndex === 'number' && currentIndex < (missingCoverageGroups.length - 1)) {
      nextIndex = currentIndex + 1;
    }

    makeCurrent(nextIndex);
  }

  return function jump(event) {
    switch (event.which) {
      case 78: // n
      case 74: // j
        goToNext();
        break;
      case 66: // b
      case 75: // k
      case 80: // p
        goToPrevious();
        break;
    }
  };
}());
window.addEventListener('keydown', jumpToCode);

insertion-text.js

目的

ハイライト範囲の後に続く空白文字やコメントなど、プログラムとして意味の無い箇所のハイライトを抑制する。

パッケージ

📦 istanbul-reports@2.0.1

コード

🚨 TypeScriptを使わない場合のパスは node_modules/istanbul-reports/lib/html/insertion-text.js である。

node_modules/nyc/node_modules/istanbul-reports/lib/html/insertion-text.js
    wrap: function (startPos, startText, endPos, endText, consumeBlanks) {
        this.insertAt(startPos, startText, true, consumeBlanks);
-       this.insertAt(endPos, endText, false, consumeBlanks);
+       this.insertAt(endPos, endText, false, false);
        return this;
    },

typescript.js

目的

ソースマップを正しく行うため。

パッケージ

📦 typescript@3.2.2

コード

🚨 TypeScriptを使う場合のみ修正する。

node_modules/typescript/lib/typescript.js
        function emitSignatureHead(node) {
            emitTypeParameters(node, node.typeParameters);
            emitParameters(node, node.parameters);
            emitTypeAnnotation(node.type);
+           if (node.original && node.original.type) {
+               emitPos(node.original.type.end);
+           } else {
+               emitPos(node.body.pos);
+           }
        }

        function emitArrowFunctionHead(node) {
            emitTypeParameters(node, node.typeParameters);
            emitParametersForArrow(node, node.parameters);
            emitTypeAnnotation(node.type);
+           emitPos(node.equalsGreaterThanToken.pos);
            writeSpace();
            emit(node.equalsGreaterThanToken);
        }

        function emitBlockFunctionBody(body) {
            writeSpace();
-           writePunctuation("{");
+           writeToken(18 /* OpenBraceToken */, body.pos, writePunctuation, body);
            increaseIndent();
            var emitBlockFunctionBody = shouldEmitBlockFunctionBodyOnSingleLine(body)
                ? emitBlockFunctionBodyOnSingleLine
                : emitBlockFunctionBodyWorker;
            if (emitBodyWithDetachedComments) {
                emitBodyWithDetachedComments(body, body.statements, emitBlockFunctionBody);
            }
            else {
                emitBlockFunctionBody(body);
            }
            decreaseIndent();
            writeToken(19 /* CloseBraceToken */, body.statements.end, writePunctuation, body);
        }

検証

修正を実施後に再度生成した各レポートが、上述のルールに従っているか検証する。

JavaScript - react-scripts@1.xのテンプレートを使用

👍 上述のルールに従ってハイライトされている。

JavaScript - react-scripts@2.xのテンプレートを使用

👍 上述のルールに従ってハイライトされている。

TypeScript - react-scripts@1.xのテンプレートを使用

👍 上述のルールに従ってハイライトされている。

TypeScript - react-scripts@2.xのテンプレートを使用

🙅 関数の定義部のハイライトが戻り値の型までカバーされていない。
🙅 クラスメソッドの public キーワードがハイライトされている。

まとめ

本記事では、IstanbulJSが生成するHTMLレポートのハイライト範囲を修正するコードを示した。この修正は#課題に示す問題により、以下の環境に限り有効である。

  • JavaScript - react-scripts@1.xのテンプレートを使用
  • JavaScript - react-scripts@2.xのテンプレートを使用
  • TypeScript - react-scripts@1.xのテンプレートを使用

課題

TypeScript - react-scripts@2.xのテンプレートを使用した場合のレポート

#TypeScript - react-scripts@2.xのテンプレートを使用した場合のレポート にて、ハイライトが上述のルールに従っていない点について、別記事 にまとめた。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?