UNPKG

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
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