Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/wet-apes-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@nomicfoundation/hardhat-node-test-runner": minor
"@nomicfoundation/hardhat-mocha": minor
"@nomicfoundation/hardhat-errors": minor
"hardhat": minor
---

Add `--gas-stats-json <path>` global option to write gas usage statistics to a JSON file ([#7990](https://github.com/NomicFoundation/hardhat/issues/7990)).
13 changes: 12 additions & 1 deletion .peer-bumps.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,16 @@
"v-next/hardhat/templates",
"v-next/config"
],
"bumps": []
"bumps": [
{
"package": "@nomicfoundation/hardhat-mocha",
"peer": "hardhat",
"reason": "hardhat-mocha now reads hre.globalOptions.gasStatsJson, which is only present in the hardhat version that added the --gas-stats-json global option"
},
{
"package": "@nomicfoundation/hardhat-node-test-runner",
"peer": "hardhat",
"reason": "hardhat-node-test-runner now reads hre.globalOptions.gasStatsJson, which is only present in the hardhat version that added the --gas-stats-json global option"
}
]
}
8 changes: 8 additions & 0 deletions v-next/hardhat-errors/src/descriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,14 @@ Please double check your arguments.`,

Please double check your script's path.`,
},
INVALID_FILE_PATH: {
number: 601,
messageTemplate: `The path "{path}" is a directory. A file path was expected.`,
websiteTitle: "Invalid file path",
websiteDescription: `A file path was expected, but a directory was provided.

Please specify a valid file path.`,
},
},
NETWORK: {
INVALID_URL: {
Expand Down
3 changes: 2 additions & 1 deletion v-next/hardhat-mocha/src/task-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ const testWithHardhat: NewTaskActionFunction<TestActionArguments> = async (

if (
hre.globalOptions.coverage === true ||
hre.globalOptions.gasStats === true
hre.globalOptions.gasStats === true ||
hre.globalOptions.gasStatsJson !== undefined
) {
const testWorkerDone = new URL(
import.meta.resolve("@nomicfoundation/hardhat-mocha/test-worker-done"),
Expand Down
3 changes: 2 additions & 1 deletion v-next/hardhat-node-test-runner/src/task-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ const testWithHardhat: NewTaskActionFunction<TestActionArguments> = async (

if (
hre.globalOptions.coverage === true ||
hre.globalOptions.gasStats === true
hre.globalOptions.gasStats === true ||
hre.globalOptions.gasStatsJson !== undefined
) {
const testWorkerDone = new URL(
import.meta.resolve(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import type { GasAnalyticsManager, GasMeasurement } from "./types.js";
import type {
ContractGasStatsJson,
GasAnalyticsManager,
GasMeasurement,
GasStatsJson,
GasStatsJsonEntry,
} from "./types.js";
import type { TableItem } from "@nomicfoundation/hardhat-utils/format";

import crypto from "node:crypto";
import path from "node:path";

import { HardhatError } from "@nomicfoundation/hardhat-errors";
import { formatTable } from "@nomicfoundation/hardhat-utils/format";
import {
ensureDir,
exists,
getAllFilesMatching,
isDirectory,
readJsonFile,
remove,
writeJsonFile,
Expand All @@ -16,6 +25,8 @@ import { findDuplicates } from "@nomicfoundation/hardhat-utils/lang";
import chalk from "chalk";
import debug from "debug";

import { parseFullyQualifiedName } from "../../../utils/contract-names.js";

const gasStatsLog = debug(
"hardhat:core:gas-analytics:gas-analytics-manager:gas-stats",
);
Expand Down Expand Up @@ -94,6 +105,32 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager {
gasStatsLog("Printed markdown report");
}

public async writeGasStatsJson(
outputPath: string,
...ids: string[]
): Promise<void> {
if (!this.#reportEnabled) {
return;
}

await this._loadGasMeasurements(...ids);

const gasStatsByContract = this._calculateGasStats();

const resolvedPath = path.resolve(outputPath);
if ((await exists(resolvedPath)) && (await isDirectory(resolvedPath))) {
throw new HardhatError(
HardhatError.ERRORS.CORE.BUILTIN_TASKS.INVALID_FILE_PATH,
{ path: outputPath },
);
}
await ensureDir(path.dirname(resolvedPath));

const json = this._generateGasStatsJson(gasStatsByContract);
await writeJsonFile(resolvedPath, json);
gasStatsLog("Written gas stats JSON to", resolvedPath);
}

public enableReport(): void {
this.#reportEnabled = true;
}
Expand Down Expand Up @@ -305,6 +342,46 @@ export class GasAnalyticsManagerImplementation implements GasAnalyticsManager {

return formatTable(rows);
}

/**
* @private exposed for testing purposes only
*/
public _generateGasStatsJson(
gasStatsByContract: GasStatsByContract,
): GasStatsJson {
const sortedContracts = [...gasStatsByContract.entries()]
.map(([internalFqn, stats]) => ({
userFqn: getUserFqn(internalFqn),
stats,
}))
.sort((a, b) => a.userFqn.localeCompare(b.userFqn));

const contracts: Record<string, ContractGasStatsJson> = {};

for (const { userFqn, stats } of sortedContracts) {
const { sourceName, contractName } = parseFullyQualifiedName(userFqn);

const deployment: GasStatsJsonEntry | null =
stats.deployment !== undefined ? { ...stats.deployment } : null;

let functions: Record<string, GasStatsJsonEntry> | null = null;
if (stats.functions.size > 0) {
functions = {};
// Sort functions by removing trailing ) and comparing alphabetically.
// This ensures that overloaded functions with fewer params come first
// (e.g., foo(uint256) comes before foo(uint256,uint256)). In other
// scenarios, removing the trailing ) has no effect on the order.
const sortedFunctions = [...stats.functions.entries()].sort(
([a], [b]) => a.split(")")[0].localeCompare(b.split(")")[0]),
);
functions = Object.fromEntries(sortedFunctions);
}

contracts[userFqn] = { sourceName, contractName, deployment, functions };
}

return { contracts };
}
}

export function avg(values: number[]): number {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { setGasAnalyticsManager } from "../helpers.js";

export default async (): Promise<Partial<HardhatRuntimeEnvironmentHooks>> => ({
created: async (context, hre) => {
if (context.globalOptions.gasStats) {
if (
context.globalOptions.gasStats ||
context.globalOptions.gasStatsJson !== undefined
) {
const gasAnalyticsManager = new GasAnalyticsManagerImplementation(
hre.config.paths.cache,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@ export default async (): Promise<Partial<TestHooks>> => ({
},
});

function isGasStatsEnabled(context: HookContext): boolean {
return (
context.globalOptions.gasStats === true ||
context.globalOptions.gasStatsJson !== undefined
);
}

export async function testRunStart(
context: HookContext,
id: string,
): Promise<void> {
if (context.globalOptions.gasStats === true) {
if (isGasStatsEnabled(context)) {
await getGasAnalyticsManager(context).clearGasMeasurements(id);
}
}
Expand All @@ -32,7 +39,7 @@ export async function testWorkerDone(
context: HookContext,
id: string,
): Promise<void> {
if (context.globalOptions.gasStats === true) {
if (isGasStatsEnabled(context)) {
await getGasAnalyticsManager(context).saveGasMeasurements(id);
}
}
Expand All @@ -44,4 +51,10 @@ export async function testRunDone(
if (context.globalOptions.gasStats === true) {
await getGasAnalyticsManager(context).reportGasStats(id);
}
if (context.globalOptions.gasStatsJson !== undefined) {
await getGasAnalyticsManager(context).writeGasStatsJson(
context.globalOptions.gasStatsJson,
id,
);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { HardhatPlugin } from "../../../types/plugins.js";

import { globalFlag, overrideTask } from "../../core/config.js";
import { ArgumentType } from "../../../types/arguments.js";
import { globalFlag, globalOption, overrideTask } from "../../core/config.js";

import "./type-extensions.js";

Expand Down Expand Up @@ -43,6 +44,13 @@ const hardhatPlugin: HardhatPlugin = {
description:
"Collects and displays gas usage statistics for all function calls during tests",
}),
globalOption({
name: "gasStatsJson",
description:
"Write gas usage statistics to a JSON file at the specified path",
type: ArgumentType.STRING_WITHOUT_DEFAULT,
defaultValue: undefined,
}),
],
hookHandlers: {
hre: () => import("./hook-handlers/hre.js"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ import "../../../types/global-options.js";
declare module "../../../types/global-options.js" {
export interface GlobalOptions {
gasStats: boolean;
gasStatsJson: string;
Comment thread
schaable marked this conversation as resolved.
Outdated
}
}
34 changes: 34 additions & 0 deletions v-next/hardhat/src/internal/builtin-plugins/gas-analytics/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
/**
* Gas statistics for a single function call or deployment (min/max/avg/median/count).
*/
export interface GasStatsJsonEntry {
min: number;
max: number;
avg: number;
median: number;
count: number;
}

/**
* Gas statistics for a single contract in the JSON output.
* `deployment` is null when the contract was never deployed during the test run
* (e.g. deployed by a factory or via forking). `functions` is null when the
* contract was deployed but no functions were called.
*/
export interface ContractGasStatsJson {
sourceName: string;
contractName: string;
deployment: GasStatsJsonEntry | null;
functions: Record<string, GasStatsJsonEntry> | null;
}

/**
* Top-level JSON output shape produced by `--gas-stats-json`.
* Contract keys are user-friendly FQNs (e.g. `contracts/Foo.sol:Foo`),
* sorted alphabetically.
*/
export interface GasStatsJson {
contracts: Record<string, ContractGasStatsJson>;
}

interface BaseGasMeasurement {
contractFqn: string;
gas: number;
Expand All @@ -21,6 +54,7 @@ export interface GasAnalyticsManager {
clearGasMeasurements(id: string): Promise<void>;
saveGasMeasurements(id: string): Promise<void>;
reportGasStats(...ids: string[]): Promise<void>;
writeGasStatsJson(outputPath: string, ...ids: string[]): Promise<void>;

enableReport(): void;
disableReport(): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ const runSolidityTests: NewTaskActionFunction<TestActionArguments> = async (
verbosity,
observability: observabilityConfig,
testPattern: grep,
generateGasReport: hre.globalOptions.gasStats,
generateGasReport:
hre.globalOptions.gasStats ||
hre.globalOptions.gasStatsJson !== undefined,
});
const tracingConfig: TracingConfigWithBuffers = {
buildInfos,
Expand Down
24 changes: 20 additions & 4 deletions v-next/hardhat/src/internal/builtin-plugins/test/task-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ const runAllTests: NewTaskActionFunction<TestActionArguments> = async (
getCoverageManager(hre).disableReport();
}

if (hre.globalOptions.gasStats === true) {
if (
hre.globalOptions.gasStats === true ||
hre.globalOptions.gasStatsJson !== undefined
) {
getGasAnalyticsManager(hre).disableReport();
}

Expand Down Expand Up @@ -235,11 +238,24 @@ const runAllTests: NewTaskActionFunction<TestActionArguments> = async (
console.log();
}

if (hre.globalOptions.gasStats === true) {
if (
hre.globalOptions.gasStats === true ||
hre.globalOptions.gasStatsJson !== undefined
) {
const gasAnalytics = getGasAnalyticsManager(hre);
gasAnalytics.enableReport();
await gasAnalytics.reportGasStats(...ranSubtaskIds);
console.log();

if (hre.globalOptions.gasStats === true) {
await gasAnalytics.reportGasStats(...ranSubtaskIds);
console.log();
}

if (hre.globalOptions.gasStatsJson !== undefined) {
await gasAnalytics.writeGasStatsJson(
hre.globalOptions.gasStatsJson,
...ranSubtaskIds,
);
}
}

if (hasFailures) {
Expand Down
Loading
Loading