@code-pushup/coverage-plugin
Version:
Code PushUp plugin for tracking code coverage ☂
147 lines • 6.32 kB
JavaScript
import path from 'node:path';
import { aggregateCoverageStats, capitalize, exists, formatAsciiTable, formatCoveragePercentage, getGitRoot, logger, objectFromEntries, objectToEntries, pluralize, pluralizeToken, readTextFile, toUnixNewlines, truncatePaths, } from '@code-pushup/utils';
import { ALL_COVERAGE_TYPES } from '../../constants.js';
import { mergeLcovResults } from './merge-lcov.js';
import { parseLcov } from './parse-lcov.js';
import { lcovCoverageToAuditOutput, recordToStatFunctionMapper, } from './transform.js';
// Note: condition or statement coverage is not supported in LCOV
// https://stackoverflow.com/questions/48260434/is-it-possible-to-check-condition-coverage-with-gcov
/**
*
* @param results Paths to LCOV results
* @param coverageTypes types of coverage to be considered
* @returns Audit outputs with complete coverage data.
*/
export async function lcovResultsToAuditOutputs(results, coverageTypes) {
// Parse lcov files
const lcovResults = await parseLcovFiles(results);
// Merge multiple coverage reports for the same file
const mergedResults = mergeLcovResults(lcovResults);
logMergedRecords({ before: lcovResults.length, after: mergedResults.length });
// Calculate code coverage from all coverage results
const totalCoverageStats = groupLcovRecordsByCoverageType(mergedResults, coverageTypes);
const gitRoot = await getGitRoot();
return coverageTypes
.map(coverageType => {
const stats = totalCoverageStats[coverageType];
if (!stats) {
return null;
}
return lcovCoverageToAuditOutput(stats, coverageType, gitRoot);
})
.filter(exists);
}
/**
*
* @param results Paths to LCOV results
* @returns Array of parsed LCOVRecords.
*/
export async function parseLcovFiles(results) {
const recordsPerReport = Object.fromEntries(await Promise.all(results.map(parseLcovFile)));
logLcovRecords(recordsPerReport);
const allRecords = Object.values(recordsPerReport).flat();
if (allRecords.length === 0) {
throw new Error('All provided coverage results are empty.');
}
return allRecords;
}
async function parseLcovFile(result) {
const resultsPath = typeof result === 'string' ? result : result.resultsPath;
const lcovFileContent = await readTextFile(resultsPath);
if (lcovFileContent.trim() === '') {
logger.warn(`Empty LCOV report file detected at ${resultsPath}.`);
}
const parsedRecords = parseLcov(toUnixNewlines(lcovFileContent));
logger.debug(`Parsed LCOV report file at ${resultsPath}`);
return [
resultsPath,
parsedRecords.map((record) => ({
title: record.title,
file: typeof result === 'string' || result.pathToProject == null
? record.file
: path.join(result.pathToProject, record.file),
functions: patchInvalidStats(record, 'functions'),
branches: patchInvalidStats(record, 'branches'),
lines: patchInvalidStats(record, 'lines'),
})),
];
}
/**
* Filters out invalid `line` numbers, and ensures `hit <= found`.
* @param record LCOV record
* @param type Coverage type
* @returns Patched stats for type in record
*/
function patchInvalidStats(record, type) {
const stats = record[type];
return {
...stats,
hit: Math.min(stats.hit, stats.found),
details: stats.details.filter(detail => detail.line > 0),
};
}
/**
* This function aggregates coverage stats from all coverage files
* @param records LCOV record for each file
* @param coverageTypes Types of coverage to be gathered
* @returns Complete coverage stats for all defined types of coverage.
*/
function groupLcovRecordsByCoverageType(records, coverageTypes) {
return records.reduce((acc, record) => objectFromEntries(objectToEntries(getCoverageStatsFromLcovRecord(record, coverageTypes)).map(([type, file]) => [type, [...(acc[type] ?? []), file]])), {});
}
/**
* @param record record file data
* @param coverageTypes types of coverage to be gathered
* @returns Relevant coverage data from one lcov record file.
*/
function getCoverageStatsFromLcovRecord(record, coverageTypes) {
return objectFromEntries(coverageTypes.map(coverageType => [
coverageType,
recordToStatFunctionMapper[coverageType](record),
]));
}
function logLcovRecords(recordsPerReport) {
const reportPaths = Object.keys(recordsPerReport);
const reportsCount = reportPaths.length;
const sourceFilesCount = new Set(Object.values(recordsPerReport)
.flat()
.map(record => record.file)).size;
logger.info(`Parsed ${pluralizeToken('LCOV report', reportsCount)}, coverage collected from ${pluralizeToken('source file', sourceFilesCount)}`);
if (!logger.isVerbose()) {
return;
}
const truncatedPaths = truncatePaths(reportPaths);
logger.newline();
logger.debug(formatAsciiTable({
columns: [
{ key: 'report', label: 'LCOV report', align: 'left' },
{ key: 'filesCount', label: 'Files', align: 'right' },
...ALL_COVERAGE_TYPES.map((type) => ({
key: type,
label: capitalize(pluralize(type)),
align: 'right',
})),
],
rows: Object.entries(recordsPerReport).map(([reportPath, records], idx) => {
const groups = groupLcovRecordsByCoverageType(records, ALL_COVERAGE_TYPES);
const stats = objectFromEntries(objectToEntries(groups).map(([type, files = []]) => [
type,
formatCoveragePercentage(aggregateCoverageStats(files)),
]));
const report = truncatedPaths[idx] ?? reportPath;
return { report, filesCount: records.length, ...stats };
}),
}));
logger.newline();
}
function logMergedRecords(counts) {
if (counts.before === counts.after) {
logger.debug(counts.after === 1
? 'There is only 1 LCOV record' // should be rare
: `All of ${pluralizeToken('LCOV record', counts.after)} have unique source files`);
}
else {
logger.info(`Merged ${counts.before} into ${pluralizeToken('unique LCOV record', counts.after)} per source file`);
}
}
//# sourceMappingURL=lcov-runner.js.map