const fs = require('fs'); const path = require('path'); const libCoverage = require('istanbul-lib-coverage'); const libReport = require('istanbul-lib-report'); const reports = require('istanbul-reports'); const glob = require('fast-glob'); const yargs = require('yargs/yargs'); const { hideBin } = require('yargs/helpers'); const yaml = require('js-yaml'); const codecovTargets = require('../coverage-targets'); const codecovConfig = yaml.load(fs.readFileSync('codecov.yml', 'utf8')); const COVERAGE_DIR = './coverage/'; /** * Load .json file at path and parse it into a javascript object * * @param {string} filePath - path to the file to load * @returns {object} the JavaScript object parsed from the file */ function loadData(filePath) { const json = fs.readFileSync(filePath); return JSON.parse(json); } /** * Loads an array of json coverage files and merges them into a final coverage * report. * * @param {string[]} files - array of strings that are paths to files * @returns {libCoverage.CoverageMap} CoverageMap */ function mergeCoverageMaps(files) { const coverageMap = libCoverage.createCoverageMap({}); files.forEach((covergeFinalFile) => { coverageMap.merge(loadData(covergeFinalFile)); }); return coverageMap; } /** * Given a target directory and a coverageMap generates a finalized coverage * summary report and saves it to the directory. * * @param {string} dir - target directory * @param {libCoverage.CoverageMap} coverageMap - CoverageMap to report on * @param reportType * @param reportOptions */ function generateSummaryReport(dir, coverageMap, reportType, reportOptions) { const context = libReport.createContext({ dir, coverageMap, }); reports.create(reportType, reportOptions ?? {}).execute(context); } /** * Generates a multiline string with coverage data * * @param {CoverageTarget} target - Target coverage threshold * @param {import('istanbul-lib-coverage').CoverageSummaryData} actual - * istanbul coverage summary detailing actual summary * @returns {string} multiline report of coverage */ function generateConsoleReport(target, actual) { const { lines, branches, functions, statements } = actual.data; const breakdown = `Lines: ${lines.covered}/${lines.total} (${lines.pct}%). Target: ${target.lines}%\n` + `Branches: ${branches.covered}/${branches.total} (${branches.pct}%). Target: ${target.branches}%\n` + `Statements: ${statements.covered}/${statements.total} (${statements.pct}%). Target: ${target.statements}%\n` + `Functions: ${functions.covered}/${functions.total} (${functions.pct}%). Target: ${target.functions}%`; return breakdown; } /** * @typedef {object} CoverageTarget * @property {number} lines - percentage of lines that must be covered * @property {number} statements - percentage of statements that must be covered * @property {number} branches - percentage of branches that must be covered * @property {number} functions - percentage of functions that must be covered */ /** * Checks if the coverage meets target * * @param {CoverageTarget} target * @param {import('istanbul-lib-coverage').CoverageSummaryData} actual * @returns {boolean} */ function isCoverageInsufficient(target, actual) { const lineCoverageNotMet = actual.lines.pct < target.lines; const branchCoverageNotMet = actual.branches.pct < target.branches; const functionCoverageNotMet = actual.functions.pct < target.functions; const statementCoverageNotMet = actual.statements.pct < target.statements; return ( lineCoverageNotMet || branchCoverageNotMet || functionCoverageNotMet || statementCoverageNotMet ); } /** * Checks if the coverage should be bumped up * * @param {CoverageTarget} target * @param {import('istanbul-lib-coverage').CoverageSummaryData} actual * @returns {boolean} */ function shouldCoverageBeBumped(target, actual) { const lineCoverageNeedsBumped = actual.lines.pct > target.lines + 5; const branchCoverageNeedsBumped = actual.branches.pct > target.branches + 5; const functionCoverageNeedsBumped = actual.functions.pct > target.functions + 5; const statementCoverageNeedsBumped = actual.statements.pct > target.statements + 5; return ( lineCoverageNeedsBumped || branchCoverageNeedsBumped || functionCoverageNeedsBumped || statementCoverageNeedsBumped ); } /** * Creates and returns a combined coverage summary report of every file in the * provided array. * * @param {string[]} files - array of files generated by fast-glob * @param {libCoverage.CoverageMap} coverageMap * @returns {import('istanbul-lib-coverage').CoverageSummaryData} */ function getFileCoverage(files, coverageMap) { const subCoverageMap = libCoverage.createCoverageMap({}); files.forEach((file) => { try { subCoverageMap.merge( coverageMap.fileCoverageFor(`${process.cwd()}/${file}`), ); } catch { // If the coverage doesn't exist, it means that it was excluded from // coverage or had no coverage to report, which is fine. Glob is a lot // wider of a net then what the test file runners match against. } }); const summary = subCoverageMap.getCoverageSummary(); return summary; } /** * Checks coverage and reports to console * Throws an error if coverage isn't met * * @param {string} name - The target's name from coverageThresholds in jest * config * @param {CoverageTarget} target - the target coverage threshold * @param {import('istanbul-lib-coverage').CoverageSummaryData} actual - * istanbul coverage summary representing actual coverage */ function checkCoverage(name, target, actual) { const breakdown = generateConsoleReport(target, actual); if (isCoverageInsufficient(target, actual)) { const errorMsg = `Coverage thresholds for ${name} NOT met\n${breakdown}`; throw new Error(errorMsg); } else if (shouldCoverageBeBumped(target, actual)) { const errorMsg = `Coverage EXCEEDS threshold for ${name} and must be bumped\n${breakdown}`; throw new Error(errorMsg); } console.log(`Coverage thresholds for ${name} met\n${breakdown}\n\n`); } /** * Primary script function */ async function start() { const { argv: { html }, } = yargs(hideBin(process.argv)).usage( '$0 [options]', 'Run unit tests on the application code.', (yargsInstance) => yargsInstance .option('html', { alias: ['h'], default: false, description: 'Generate HTML report', type: 'boolean', }) .strict(), ); // First get all of the files matching the pattern coverage-final-${n}.json // from the coverage directory const files = fs.readdirSync(COVERAGE_DIR); const filePaths = files .filter( (file) => path.basename(file).startsWith('coverage-final') && path.extname(file) === '.json', ) .map((file) => path.join(COVERAGE_DIR, file)); // Next, generate a coverageMap const coverageMap = mergeCoverageMaps(filePaths, true); // Persist this to file, which may eventually be used in more steps generateSummaryReport(COVERAGE_DIR, coverageMap, 'json-summary'); if (html) { generateSummaryReport(COVERAGE_DIR, coverageMap, 'html'); } // Use the keys in coverageThreshold in jest config to determine targets const coverageTargets = Object.keys(codecovConfig.coverage.status.project); // Check coverage totals for each target coverageTargets.forEach((target) => { const summary = target === 'global' ? coverageMap.getCoverageSummary() : getFileCoverage( glob.sync([ ...codecovConfig.coverage.status.project[target].paths, // checking test file coverage is redundant. '!**/*.test.js', '!**/__mocks__/**/*.js', '!**/*.stories.js', ]), coverageMap, ); // Check and validate the coverage checkCoverage(target, codecovTargets[target], summary); }); } start().catch((error) => { // Report the errored coverage check console.error(error); process.exit(1); });