const fs = require("fs").promises; const xml2js = require("xml2js"); const util = require("util"); const glob = require("glob-promise"); const parseString = util.promisify(xml2js.parseString); /** * generate the report for the given file * * @param path: string * @param options: object * @return {Promise<{total: number, line: number, files: T[], branch: number}>} */ async function readCoverageFromFile(path, options) { const xml = await fs.readFile(path, "utf-8"); const { coverage } = await parseString(xml, { explicitArray: false, mergeAttrs: true, }); const { packages } = coverage; const classes = processPackages(packages); const files = classes .filter(Boolean) .map((klass) => { return { ...calculateRates(klass), filename: klass["filename"], name: klass["name"], missing: missingLines(klass), }; }) .filter((file) => options.skipCovered === false || file.total < 100); return { ...calculateRates(coverage), files, }; } function trimFolder(path, positionOfFirstDiff) { const lastFolder = path.lastIndexOf("/") + 1; if (positionOfFirstDiff >= lastFolder) { return path.substr(lastFolder); } else { const startOffset = Math.min(positionOfFirstDiff - 1, lastFolder); const length = path.length - startOffset - lastFolder - 2; // remove filename return path.substr(startOffset, length); } } /** * * @param path: string * @param options: {} * @returns {Promise<{total: number, folder: string, line: number, files: T[], branch: number}[]>} */ async function processCoverage(path, options) { options = options || { skipCovered: false }; const paths = glob.hasMagic(path) ? await glob(path) : [path]; const positionOfFirstDiff = longestCommonPrefix(paths); return await Promise.all( paths.map(async (path) => { const report = await readCoverageFromFile(path, options); const folder = trimFolder(path, positionOfFirstDiff); return { ...report, folder, }; }) ); } function processPackages(packages) { if (packages.package instanceof Array) { return packages.package.map((p) => processPackage(p)).flat(); } else if (packages.package) { return processPackage(packages.package); } else { return processPackage(packages); } } function processPackage(packageObj) { if (packageObj.classes && packageObj.classes.class instanceof Array) { return packageObj.classes.class; } else if (packageObj.classes && packageObj.classes.class) { return [packageObj.classes.class]; } else if (packageObj.class && packageObj.class instanceof Array) { return packageObj.class; } else { return [packageObj.class]; } } /** * returns coverage rates * * @param element: object * @returns {{total: number, line: number, branch: number}} */ function calculateRates(element) { const line = parseFloat(element["line-rate"]) * 100; const branch = parseFloat(element["branch-rate"]) * 100; const total = line && branch ? (line + branch) / 2 : line; return { total, line, branch, }; } function getLines(klass) { if (klass.lines && klass.lines.line instanceof Array) { return klass.lines.line; } else if (klass.lines && klass.lines.line) { return [klass.lines.line]; } else { return []; } } function missingLines(klass) { // Bail if line-rate says fully covered if (parseFloat(klass["line-rate"]) >= 1.0) return ""; const lines = getLines(klass).sort( (a, b) => parseInt(a.number) - parseInt(b.number) ); const statements = lines.map((line) => line.number); const misses = lines .filter((line) => parseInt(line.hits) < 1) .map((line) => line.number); return partitionLines(statements, misses); } function partitionLines(statements, lines) { /* * Detect sequences, with gaps according to 'statements', * in 'lines' and compress them in to a range format. * * Example: * * statements = [1,2,3,4,5,10,11,12,13,14,15,16] * lines = [1,2, 5,10,11, 13,14, ,16] * Returns: [[1, 2], [5, 11], [13, 14], [16, 16]] */ const ranges = []; let start = null; let linesCursor = 0; let end; for (const statement of statements) { if (linesCursor >= lines.length) break; if (statement === lines[linesCursor]) { // (Consecutive) element from 'statements' matches // element from 'lines' at 'linesCursor' linesCursor += 1; if (start === null) start = statement; end = statement; } else if (start !== null) { // Consecutive elements are broken, an element from // 'statements' is missing from 'lines' ranges.push([start, end]); start = null; } } // (Eventually) close range running last iteration if (start !== null) ranges.push([start, end]); return ranges; } /** * * @param paths: [string] * @returns number */ function longestCommonPrefix(paths) { let prefix = ""; if (paths === null || paths.length === 0) return 0; for (let i = 0; i < paths[0].length; i++) { const char = paths[0][i]; // loop through all characters of the very first string. for (let j = 1; j < paths.length; j++) { // loop through all other strings in the array if (paths[j][i] !== char) return prefix.length; } prefix = prefix + char; } return prefix.length; } module.exports = { processCoverage, trimFolder, longestCommonPrefix, };