hardhat
Version:
Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.
333 lines • 13.6 kB
JavaScript
import path from "node:path";
import { divider, formatTable } from "@nomicfoundation/hardhat-utils/format";
import { ensureDir, getAllFilesMatching, readJsonFile, readUtf8File, remove, writeJsonFile, writeUtf8File, } from "@nomicfoundation/hardhat-utils/fs";
import chalk from "chalk";
import debug from "debug";
import { getProcessedCoverageInfo } from "./process-coverage.js";
import { generateHtmlReport } from "./reports/html.js";
const log = debug("hardhat:core:coverage:coverage-manager");
const MAX_COLUMN_WIDTH = 80;
export class CoverageManagerImplementation {
/**
* @private exposed for testing purposes only
*/
filesMetadata = new Map();
/**
* @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) {
for (const entry of metadata) {
log("Added metadata", JSON.stringify(metadata, null, 2));
let fileStatements = this.filesMetadata.get(entry.relativePath);
if (fileStatements === undefined) {
fileStatements = new Map();
this.filesMetadata.set(entry.relativePath, fileStatements);
}
const key = `${entry.relativePath}-${entry.tag}-${entry.startUtf16}-${entry.endUtf16}`;
const existingData = fileStatements.get(key);
if (existingData === undefined) {
fileStatements.set(key, entry);
}
}
}
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 = await 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}`);
const htmlReportPath = path.join(this.#coveragePath, "html");
await generateHtmlReport(report, htmlReportPath);
console.log(`Saved html report to ${htmlReportPath}`);
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
*/
async getReport() {
const allExecutedTags = new Set(this.data);
const reportPromises = Array.from(this.filesMetadata.entries()).map(async ([fileRelativePath, fileStatements]) => {
const statements = Array.from(fileStatements.values());
const fileContent = await readUtf8File(path.join(process.cwd(), fileRelativePath));
const tags = new Set();
let executedStatementsCount = 0;
let unexecutedStatementsCount = 0;
for (const { tag } of statements) {
if (allExecutedTags.has(tag)) {
tags.add(tag);
executedStatementsCount++;
}
else {
unexecutedStatementsCount++;
}
}
const coverageInfo = getProcessedCoverageInfo(fileContent, statements, tags);
const lineExecutionCounts = new Map();
coverageInfo.lines.executed.forEach((_, line) => lineExecutionCounts.set(line, 1));
coverageInfo.lines.unexecuted.forEach((_, line) => lineExecutionCounts.set(line, 0));
const executedLinesCount = coverageInfo.lines.executed.size;
const unexecutedLines = new Set(coverageInfo.lines.unexecuted.keys());
return {
path: fileRelativePath,
data: {
lineExecutionCounts,
executedStatementsCount,
unexecutedStatementsCount,
executedLinesCount,
unexecutedLines,
},
};
});
const results = await Promise.all(reportPromises);
return Object.fromEntries(results.map((r) => [r.path, r.data]));
}
/**
* @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, { 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>
// TODO: currently EDR does not provide branch coverage information.
// 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"].map((s) => chalk.yellow(s)));
const bodyRows = Object.entries(report).map(([relativePath, { executedStatementsCount, unexecutedStatementsCount, lineExecutionCounts, executedLinesCount, unexecutedLines, },]) => {
const lineCoverage = lineExecutionCounts.size === 0
? 0
: (executedLinesCount * 100.0) / lineExecutionCounts.size;
const statementCoverage = executedStatementsCount === 0
? 0
: (executedStatementsCount * 100.0) /
(executedStatementsCount + unexecutedStatementsCount);
totalExecutedLines += executedLinesCount;
totalExecutableLines += lineExecutionCounts.size;
totalExecutedStatements += executedStatementsCount;
totalExecutableStatements +=
executedStatementsCount + unexecutedStatementsCount;
const row = [
this.formatRelativePath(relativePath),
this.formatCoverage(lineCoverage),
this.formatCoverage(statementCoverage),
this.formatLines(unexecutedLines),
];
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