UNPKG

hardhat

Version:

Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.

561 lines (462 loc) 16 kB
import type { CoverageData, CoverageManager, CoverageMetadata, Statement, Tag, } from "./types.js"; import type { TableItem } from "@nomicfoundation/hardhat-utils/format"; 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; type Line = number; type Branch = [Line, Tag]; /** * @private exposed for testing purposes only */ export interface Report { [relativePath: string]: { tagExecutionCounts: Map<Tag, number>; lineExecutionCounts: Map<Line, number>; branchExecutionCounts: Map<Branch, number>; executedTagsCount: number; executedLinesCount: number; executedBranchesCount: number; partiallyExecutedLines: Set<Line>; unexecutedLines: Set<Line>; }; } export class CoverageManagerImplementation implements CoverageManager { /** * @private exposed for testing purposes only */ public metadata: CoverageMetadata = []; /** * @private exposed for testing purposes only */ public data: CoverageData = []; readonly #coveragePath: string; #reportEnabled = true; constructor(coveragePath: string) { this.#coveragePath = coveragePath; } async #getDataPath(id: string): Promise<string> { const dataPath = path.join(this.#coveragePath, "data", id); await ensureDir(dataPath); return dataPath; } public async addData(data: CoverageData): Promise<void> { for (const entry of data) { this.data.push(entry); } log("Added data", JSON.stringify(data, null, 2)); } public async addMetadata(metadata: CoverageMetadata): Promise<void> { // 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)); } public async clearData(id: string): Promise<void> { const dataPath = await this.#getDataPath(id); await remove(dataPath); this.data = []; log("Cleared data from disk and memory"); } public async saveData(id: string): Promise<void> { 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); } public async report(...ids: string[]): Promise<void> { 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"); } public enableReport(): void { this.#reportEnabled = true; } public disableReport(): void { this.#reportEnabled = false; } /** * @private exposed for testing purposes only */ public async loadData(...ids: string[]): Promise<void> { 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<CoverageData>(filePath); for (const entry of entries) { this.data.push(entry); } log("Loaded data", id, filePath); } } } /** * @private exposed for testing purposes only */ public getReport(): Report { const report: 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<string, Statement>(); for (const statement of allStatements) { statementsByTag.set(statement.tag, statement); } const allExecutedTags = this.data; const allExecutedStatementsByRelativePath = new Map<string, Statement[]>(); 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< string, Statement[] >(); 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<Tag, number>(); for (const statement of allExecutedStatements) { const tagExecutionCount = tagExecutionCounts.get(statement.tag) ?? 0; tagExecutionCounts.set(statement.tag, tagExecutionCount + 1); } const lineExecutionCounts = new Map<number, number>(); const branchExecutionCounts = new Map<Branch, number>(); 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<number>(); const unexecutedLines = new Set<number>(); 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 */ public formatLcovReport(report: Report): string { // 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 */ public formatRelativePath(relativePath: string): string { 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 */ public formatCoverage(coverage: number): string { return coverage.toFixed(2).toString(); } /** * @private exposed for testing purposes only */ public formatLines(lines: Set<number>): string { 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: string; 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 */ public formatMarkdownReport(report: Report): string { let totalExecutedLines = 0; let totalExecutableLines = 0; let totalExecutedStatements = 0; let totalExecutableStatements = 0; const rows: TableItem[] = []; 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: string[] = [ 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); } }