hardhat
Version:
Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.
377 lines • 16 kB
JavaScript
import path from "node:path";
import { assertHardhatInvariant } from "@nomicfoundation/hardhat-errors";
import { divider, formatTable } from "@nomicfoundation/hardhat-utils/format";
import { ensureDir, getAllFilesMatching, readJsonFile, remove, writeJsonFile, writeUtf8File, } from "@nomicfoundation/hardhat-utils/fs";
import chalk from "chalk";
import debug from "debug";
const log = debug("hardhat:core:coverage:coverage-manager");
const MAX_COLUMN_WIDTH = 80;
export class CoverageManagerImplementation {
/**
* @private exposed for testing purposes only
*/
metadata = [];
/**
* @private exposed for testing purposes only
*/
data = [];
#coveragePath;
#reportEnabled = true;
constructor(coveragePath) {
this.#coveragePath = coveragePath;
}
async #getDataPath(id) {
const dataPath = path.join(this.#coveragePath, "data", id);
await ensureDir(dataPath);
return dataPath;
}
async addData(data) {
for (const entry of data) {
this.data.push(entry);
}
log("Added data", JSON.stringify(data, null, 2));
}
async addMetadata(metadata) {
// NOTE: The received metadata might contain duplicates. We deduplicate it
// when we generate the report.
for (const entry of metadata) {
this.metadata.push(entry);
}
log("Added metadata", JSON.stringify(metadata, null, 2));
}
async clearData(id) {
const dataPath = await this.#getDataPath(id);
await remove(dataPath);
this.data = [];
log("Cleared data from disk and memory");
}
async saveData(id) {
const dataPath = await this.#getDataPath(id);
const filePath = path.join(dataPath, `${crypto.randomUUID()}.json`);
const data = this.data;
await writeJsonFile(filePath, data);
log("Saved data", id, filePath);
}
async report(...ids) {
if (!this.#reportEnabled) {
return;
}
await this.loadData(...ids);
const report = this.getReport();
const lcovReport = this.formatLcovReport(report);
const markdownReport = this.formatMarkdownReport(report);
const lcovReportPath = path.join(this.#coveragePath, "lcov.info");
await writeUtf8File(lcovReportPath, lcovReport);
log(`Saved lcov report to ${lcovReportPath}`);
console.log(markdownReport);
console.log();
log("Printed markdown report");
}
enableReport() {
this.#reportEnabled = true;
}
disableReport() {
this.#reportEnabled = false;
}
/**
* @private exposed for testing purposes only
*/
async loadData(...ids) {
this.data = [];
for (const id of ids) {
const dataPath = await this.#getDataPath(id);
const filePaths = await getAllFilesMatching(dataPath);
for (const filePath of filePaths) {
const entries = await readJsonFile(filePath);
for (const entry of entries) {
this.data.push(entry);
}
log("Loaded data", id, filePath);
}
}
}
/**
* @private exposed for testing purposes only
*/
getReport() {
const report = {};
const relativePaths = this.metadata.map(({ relativePath }) => relativePath);
const allStatements = this.metadata;
// NOTE: We preserve only the last statement per tag in the statementsByTag map.
const statementsByTag = new Map();
for (const statement of allStatements) {
statementsByTag.set(statement.tag, statement);
}
const allExecutedTags = this.data;
const allExecutedStatementsByRelativePath = new Map();
for (const tag of allExecutedTags) {
// NOTE: We should not encounter an executed tag we don't have metadata for.
const statement = statementsByTag.get(tag);
assertHardhatInvariant(statement !== undefined, "Expected a statement");
const relativePath = statement.relativePath;
const allExecutedStatements = allExecutedStatementsByRelativePath.get(relativePath) ?? [];
allExecutedStatements.push(statement);
allExecutedStatementsByRelativePath.set(relativePath, allExecutedStatements);
}
const uniqueExecutedTags = new Set(allExecutedTags);
const uniqueUnexecutedTags = Array.from(statementsByTag.keys()).filter((tag) => !uniqueExecutedTags.has(tag));
const uniqueUnexecutedStatementsByRelativePath = new Map();
for (const tag of uniqueUnexecutedTags) {
// NOTE: We cannot encounter an executed tag we don't have metadata for.
const statement = statementsByTag.get(tag);
assertHardhatInvariant(statement !== undefined, "Expected a statement");
const relativePath = statement.relativePath;
const unexecutedStatements = uniqueUnexecutedStatementsByRelativePath.get(relativePath) ?? [];
unexecutedStatements.push(statement);
uniqueUnexecutedStatementsByRelativePath.set(relativePath, unexecutedStatements);
}
for (const relativePath of relativePaths) {
const allExecutedStatements = allExecutedStatementsByRelativePath.get(relativePath) ?? [];
const uniqueUnexecutedStatements = uniqueUnexecutedStatementsByRelativePath.get(relativePath) ?? [];
const tagExecutionCounts = new Map();
for (const statement of allExecutedStatements) {
const tagExecutionCount = tagExecutionCounts.get(statement.tag) ?? 0;
tagExecutionCounts.set(statement.tag, tagExecutionCount + 1);
}
const lineExecutionCounts = new Map();
const branchExecutionCounts = new Map();
for (const [tag, executionCount] of tagExecutionCounts) {
const statement = statementsByTag.get(tag);
assertHardhatInvariant(statement !== undefined, "Expected a statement");
for (let line = statement.startLine; line <= statement.endLine; line++) {
const lineExecutionCount = lineExecutionCounts.get(line) ?? 0;
lineExecutionCounts.set(line, lineExecutionCount + executionCount);
const branchExecutionCount = branchExecutionCounts.get([line, tag]) ?? 0;
branchExecutionCounts.set([line, tag], branchExecutionCount + executionCount);
}
}
const executedTagsCount = tagExecutionCounts.size;
const executedLinesCount = lineExecutionCounts.size;
const executedBranchesCount = branchExecutionCounts.size;
const partiallyExecutedLines = new Set();
const unexecutedLines = new Set();
for (const statement of uniqueUnexecutedStatements) {
if (!tagExecutionCounts.has(statement.tag)) {
tagExecutionCounts.set(statement.tag, 0);
}
for (let line = statement.startLine; line <= statement.endLine; line++) {
if (!lineExecutionCounts.has(line)) {
lineExecutionCounts.set(line, 0);
unexecutedLines.add(line);
}
else {
partiallyExecutedLines.add(line);
}
if (!branchExecutionCounts.has([line, statement.tag])) {
branchExecutionCounts.set([line, statement.tag], 0);
}
}
}
report[relativePath] = {
tagExecutionCounts,
lineExecutionCounts,
branchExecutionCounts,
executedTagsCount,
executedLinesCount,
executedBranchesCount,
partiallyExecutedLines,
unexecutedLines,
};
}
return report;
}
/**
* @private exposed for testing purposes only
*/
formatLcovReport(report) {
// NOTE: Format follows the guidelines set out in:
// https://github.com/linux-test-project/lcov/blob/df03ba434eee724bfc2b27716f794d0122951404/man/geninfo.1#L1409
let lcov = "";
// A tracefile is made up of several human-readable lines of text, divided
// into sections.
// If available, a tracefile begins with the testname which is stored in the
// following format:
// TN:<test name>
lcov += "TN:\n";
// For each source file referenced in the .gcda file, there is a section
// containing filename and coverage data:
// SF:<path to the source file>
for (const [relativePath, { branchExecutionCounts, executedBranchesCount, lineExecutionCounts, executedLinesCount, },] of Object.entries(report)) {
lcov += `SF:${relativePath}\n`;
// NOTE: We report statement coverage as branches to get partial line coverage
// data in tools parsing the lcov files. This is because the lcov format
// does not support statement coverage.
// WARN: This feature is highly experimental and should not be relied upon.
// Branch coverage information is stored one line per branch:
// BRDA:<line_number>,[<exception>]<block>,<branch>,<taken>
// Branch coverage summaries are stored in two lines:
// BRF:<number of branches found>
// BRH:<number of branches hit>
for (const [[line, tag], executionCount] of branchExecutionCounts) {
lcov += `BRDA:${line},0,${tag},${executionCount === 0 ? "-" : executionCount}\n`;
}
lcov += `BRH:${executedBranchesCount}\n`;
lcov += `BRF:${branchExecutionCounts.size}\n`;
// Then there is a list of execution counts for each instrumented line
// (i.e. a line which resulted in executable code):
// DA:<line number>,<execution count>[,<checksum>]
// At the end of a section, there is a summary about how many lines
// were found and how many were actually instrumented:
// LH:<number of lines with a non\-zero execution count>
// LF:<number of instrumented lines>
for (const [line, executionCount] of lineExecutionCounts) {
lcov += `DA:${line},${executionCount}\n`;
}
lcov += `LH:${executedLinesCount}\n`;
lcov += `LF:${lineExecutionCounts.size}\n`;
// Each sections ends with:
// end_of_record
lcov += "end_of_record\n";
}
return lcov;
}
/**
* @private exposed for testing purposes only
*/
formatRelativePath(relativePath) {
if (relativePath.length <= MAX_COLUMN_WIDTH) {
return relativePath;
}
const prefix = "…";
const pathParts = relativePath.split(path.sep);
const parts = [pathParts[pathParts.length - 1]];
let partsLength = parts[0].length;
for (let i = pathParts.length - 2; i >= 0; i--) {
const part = pathParts[i];
if (partsLength +
part.length +
prefix.length +
(parts.length + 1) * path.sep.length <=
MAX_COLUMN_WIDTH) {
parts.push(part);
partsLength += part.length;
}
else {
break;
}
}
parts.push(prefix);
return parts.reverse().join(path.sep);
}
/**
* @private exposed for testing purposes only
*/
formatCoverage(coverage) {
return coverage.toFixed(2).toString();
}
/**
* @private exposed for testing purposes only
*/
formatLines(lines) {
if (lines.size === 0) {
return "-";
}
const sortedLines = Array.from(lines).toSorted((a, b) => a - b);
const intervals = [];
let intervalsLength = 0;
let startLine = sortedLines[0];
let endLine = sortedLines[0];
for (let i = 1; i <= sortedLines.length; i++) {
if (i < sortedLines.length && sortedLines[i] === endLine + 1) {
endLine = sortedLines[i];
}
else {
let interval;
if (startLine === endLine) {
interval = startLine.toString();
}
else {
interval = `${startLine}-${endLine}`;
}
intervals.push(interval);
intervalsLength += interval.length;
if (i < sortedLines.length) {
startLine = sortedLines[i];
endLine = sortedLines[i];
}
}
}
const sep = ", ";
const suffixSep = ",";
const suffix = "…";
if (intervalsLength + (intervals.length - 1) * sep.length <=
MAX_COLUMN_WIDTH) {
return intervals.join(sep);
}
while (intervalsLength +
(intervals.length - 1) * sep.length +
suffix.length +
suffixSep.length >
MAX_COLUMN_WIDTH) {
const interval = intervals.pop();
if (interval !== undefined) {
intervalsLength -= interval.length;
}
else {
break;
}
}
return [intervals.join(sep), suffix].join(suffixSep);
}
/**
* @private exposed for testing purposes only
*/
formatMarkdownReport(report) {
let totalExecutedLines = 0;
let totalExecutableLines = 0;
let totalExecutedStatements = 0;
let totalExecutableStatements = 0;
const rows = [];
rows.push([chalk.bold("Coverage Report")]);
rows.push(divider);
rows.push([
"File Path",
"Line %",
"Statement %",
"Uncovered Lines",
"Partially Covered Lines",
].map((s) => chalk.yellow(s)));
const bodyRows = Object.entries(report).map(([relativePath, { tagExecutionCounts, lineExecutionCounts, executedTagsCount, executedLinesCount, unexecutedLines, partiallyExecutedLines, },]) => {
const lineCoverage = lineExecutionCounts.size === 0
? 0
: (executedLinesCount * 100.0) / lineExecutionCounts.size;
const statementCoverage = tagExecutionCounts.size === 0
? 0
: (executedTagsCount * 100.0) / tagExecutionCounts.size;
totalExecutedLines += executedLinesCount;
totalExecutableLines += lineExecutionCounts.size;
totalExecutedStatements += executedTagsCount;
totalExecutableStatements += tagExecutionCounts.size;
const row = [
this.formatRelativePath(relativePath),
this.formatCoverage(lineCoverage),
this.formatCoverage(statementCoverage),
this.formatLines(unexecutedLines),
this.formatLines(partiallyExecutedLines),
];
return row;
});
rows.push(...bodyRows);
const totalLineCoverage = totalExecutableLines === 0
? 0
: (totalExecutedLines * 100.0) / totalExecutableLines;
const totalStatementCoverage = totalExecutableStatements === 0
? 0
: (totalExecutedStatements * 100.0) / totalExecutableStatements;
rows.push(divider);
rows.push([
chalk.yellow("Total"),
this.formatCoverage(totalLineCoverage),
this.formatCoverage(totalStatementCoverage),
"",
"",
]);
return formatTable(rows);
}
}
//# sourceMappingURL=coverage-manager.js.map