diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..df66f60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +concurrency: + group: ${{github.workflow}}-${{github.ref}} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22, 24] + steps: + - uses: actions/checkout@v5 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "pnpm" + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + - name: Build + run: pnpm build + - name: Test + run: pnpm test + - name: Lint + run: pnpm lint diff --git a/.gitignore b/.gitignore index cac68ea..2ff0549 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,2 @@ # Node modules /node_modules - -# Compilation output -/dist - -# pnpm deploy output -/bundle - -# test coverage output -coverage - -# all the tmp folders in the fixture projects -/test/fixture-projects/tmp/ - -# Hardhat project files -/artifacts -/cache diff --git a/README.md b/README.md index aed734f..7964dce 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ # Hardhat 3 plugin template + +This repository is a template for creating a Hardhat 3 plugin. + +## Getting started + +> This repository is structured as a pnpm monorepo, so make sure you have [`pnpm`](https://pnpm.io/) installed first + +To get started, clone the repository and run: + +```sh +pnpm install +pnpm build +``` + +This will install all the dependencies and build the plugin. + +You can now run the tests of the plugin with: + +```sh +pnpm test +``` + +And try the plugin out in `packages/example-project` with: + +```sh +cd packages/example-project +pnpm hardhat my-task +``` + +which should print `Hola, Hardhat!`. + +## Understanding the repository structure + +### Monorepo structure + +This repository is structured as a pnpm monorepo with the following packages: + +- `packages/plugin`: The plugin itself. +- `packages/example-project`: An example Hardhat 3 project that uses the plugin. + +All the development will happen in the `packages/plugin` directory, while `packages/example-project` is a playground to experiment with your plugin, and manually test it. + +### Github Actions setup + +This repository is setup with a Github Actions workflow. You don't need to do anything to set it up, it runs your on every push to `main`, on pull requests, and when manual triggered. + +The workflow is equivalent to running this steps in the root of the repository: + +```sh +pnpm install +pnpm build +pnpm test +pnpm lint +``` + +It runs using Node.js versions 22 and 24, on an `ubuntu-latest` runner. + +## Development tips + +- We recommend leaving a terminal with `pnpm watch` running in the root of the repository. That way, things will normally be rebuilt by the time you try them out in `packages/example-project`. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 09104ca..0000000 --- a/TODO.md +++ /dev/null @@ -1,5 +0,0 @@ -[] package.json: Repository? -[] package.json: Homepage? -[] package.json: Author? -[] dependencies: hardhat-test-utils? hardhat-plugin-testing-utils? -[] tsconfig: "isolatedModules": true, "isolatedDeclarations": true,? too pedantic? diff --git a/package.json b/package.json index 142fda4..85e3173 100644 --- a/package.json +++ b/package.json @@ -1,57 +1,12 @@ { - "name": "hardhat-plugin-template", - "version": "1.0.0", - "description": "Hardhat 3 plugin template", - "license": "MIT", - "type": "module", - "types": "dist/src/index.d.ts", - "exports": { - ".": "./dist/src/index.js" - }, - "keywords": [ - "ethereum", - "smart-contracts", - "hardhat", - "hardhat3", - "hardhat-plugin" - ], + "name": "hardhat3-plugin-template", + "private": true, "scripts": { - "lint": "pnpm prettier --check && pnpm eslint", - "lint:fix": "pnpm prettier --write && pnpm eslint --fix", - "eslint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", - "prettier": "prettier \"**/*.{ts,js,md,json}\"", - "test": "node --import tsx/esm --test --test-reporter=@nomicfoundation/hardhat-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", - "test:only": "node --import tsx/esm --test --test-only --test-reporter=@nomicfoundation/hardhat-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", - "test:coverage": "c8 --reporter html --reporter text --all --exclude test --exclude \"src/**/{types,type-extensions}.ts\" --src src node --import tsx/esm --test --test-reporter=@nomicfoundation/hardhat-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", - "pretest": "pnpm build", - "pretest:only": "pnpm build", - "build": "tsc --build .", - "prepublishOnly": "pnpm build", - "clean": "rimraf dist" - }, - "files": [ - "dist/src/", - "src/", - "CHANGELOG.md", - "LICENSE", - "README.md" - ], - "devDependencies": { - "@eslint/js": "^9.35.0", - "@nomicfoundation/hardhat-node-test-reporter": "^3.0.0", - "@tsconfig/node22": "^22.0.2", - "@types/node": "^20.14.9", - "c8": "^9.1.0", - "eslint": "^9.35.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-import": "^2.32.0", - "prettier": "3.2.5", - "rimraf": "^5.0.5", - "tsx": "^4.19.3", - "typescript": "~5.8.0", - "typescript-eslint": "^8.43.0" - }, - "peerDependencies": { - "hardhat": "^3.0.6" + "build": "pnpm --recursive build", + "clean": "pnpm --recursive clean", + "lint": "pnpm --recursive lint", + "lint:fix": "pnpm --recursive lint:fix", + "test": "pnpm --recursive test", + "watch": "pnpm --filter ./packages/plugin watch" } } diff --git a/packages/example-project/.gitignore b/packages/example-project/.gitignore new file mode 100644 index 0000000..b10ecff --- /dev/null +++ b/packages/example-project/.gitignore @@ -0,0 +1,12 @@ +# Node modules +/node_modules + +# Compilation output +/dist + +# test coverage output +/coverage + +# Hardhat files +/artifacts +/cache diff --git a/packages/example-project/README.md b/packages/example-project/README.md new file mode 100644 index 0000000..0e7bd67 --- /dev/null +++ b/packages/example-project/README.md @@ -0,0 +1,38 @@ +# A Hardhat 3 project that uses your plugin + +This is an example project that uses your plugin. + +## Getting started + +To run this project, you need to install the dependencies and build the plugin: + +```sh +pnpm install +pnpm build +``` + +Then, you can run hardhat with: + +```sh +pnpm hardhat my-task +``` + +You can also run an example script with: + +```sh +pnpm hardhat run scripts/example-script.ts +``` + +And the project's solidity tests with: + +```sh +pnpm hardhat test +``` + +## What's inside the project? + +This project is similar to what you get when initializing a Hardhat 3 project with `npx hardhat --init`, but without any of the Hardhat toolboxes. + +This means that you don't have `ethers,` `viem`, `mocha`, nor the Node.js test runner plugins. + +Please install whichever dependency or plugin you need in here. This package won't be published, so you have complete freedom to do whatever you want. diff --git a/packages/example-project/contracts/Counter.sol b/packages/example-project/contracts/Counter.sol new file mode 100644 index 0000000..24b8149 --- /dev/null +++ b/packages/example-project/contracts/Counter.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +contract Counter { + uint public x; + + event Increment(uint by); + + function inc() public { + x++; + emit Increment(1); + } + + function incBy(uint by) public { + require(by > 0, "incBy: increment should be positive"); + x += by; + emit Increment(by); + } +} diff --git a/packages/example-project/contracts/Counter.t.sol b/packages/example-project/contracts/Counter.t.sol new file mode 100644 index 0000000..1899814 --- /dev/null +++ b/packages/example-project/contracts/Counter.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import {Counter} from "./Counter.sol"; +import {Test} from "forge-std/Test.sol"; + +contract CounterTest is Test { + Counter counter; + + function setUp() public { + counter = new Counter(); + } + + function test_InitialValue() public view { + require(counter.x() == 0, "Initial value should be 0"); + } + + function testFuzz_Inc(uint8 x) public { + for (uint8 i = 0; i < x; i++) { + counter.inc(); + } + require( + counter.x() == x, + "Value after calling inc x times should be x" + ); + } + + function test_IncByZero() public { + vm.expectRevert(); + counter.incBy(0); + } +} diff --git a/hardhat.config.ts b/packages/example-project/hardhat.config.ts similarity index 52% rename from hardhat.config.ts rename to packages/example-project/hardhat.config.ts index 82d0b37..0f63602 100644 --- a/hardhat.config.ts +++ b/packages/example-project/hardhat.config.ts @@ -1,6 +1,10 @@ import { HardhatUserConfig } from "hardhat/config"; -import myPlugin from "./src/index.js"; +import myPlugin from "hardhat-plugin-template"; export default { plugins: [myPlugin], + solidity: "0.8.29", + myConfig: { + greeting: "Hola", + }, } satisfies HardhatUserConfig; diff --git a/packages/example-project/package.json b/packages/example-project/package.json new file mode 100644 index 0000000..455a1be --- /dev/null +++ b/packages/example-project/package.json @@ -0,0 +1,18 @@ +{ + "name": "example-project", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --build", + "watch": "tsc --build . --watch" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/node": "^22.11.0", + "hardhat": "^3.0.6", + "hardhat-plugin-template": "workspace:*", + "typescript": "~5.8.0", + "forge-std": "github:foundry-rs/forge-std#v1.9.4" + } +} diff --git a/packages/example-project/scripts/example-script.ts b/packages/example-project/scripts/example-script.ts new file mode 100644 index 0000000..7c0c079 --- /dev/null +++ b/packages/example-project/scripts/example-script.ts @@ -0,0 +1,17 @@ +import { network } from "hardhat"; + +console.log("Running example script"); +const { provider } = await network.connect(); + +const accounts = await provider.send("eth_accounts", []); + +console.log("Accounts:", accounts); + +console.log(`Sending 1wei from ${accounts[0]} to ${accounts[1]}...`); + +const tx = await provider.request({ + method: "eth_sendTransaction", + params: [{ from: accounts[0], to: accounts[1], value: "0x1" }], +}); + +console.log(`Successfully sent transaction with hash ${tx}`); diff --git a/tsconfig.json b/packages/example-project/tsconfig.json similarity index 94% rename from tsconfig.json rename to packages/example-project/tsconfig.json index 6cb7371..124103e 100644 --- a/tsconfig.json +++ b/packages/example-project/tsconfig.json @@ -12,6 +12,7 @@ "sourceMap": true, "composite": true, "incremental": true, + "isolatedModules": true, "typeRoots": ["${configDir}/node_modules/@types"] }, "exclude": ["${configDir}/dist", "${configDir}/node_modules"] diff --git a/packages/plugin/.gitignore b/packages/plugin/.gitignore new file mode 100644 index 0000000..b10ecff --- /dev/null +++ b/packages/plugin/.gitignore @@ -0,0 +1,12 @@ +# Node modules +/node_modules + +# Compilation output +/dist + +# test coverage output +/coverage + +# Hardhat files +/artifacts +/cache diff --git a/.prettierignore b/packages/plugin/.prettierignore similarity index 100% rename from .prettierignore rename to packages/plugin/.prettierignore diff --git a/LICENSE b/packages/plugin/LICENSE similarity index 100% rename from LICENSE rename to packages/plugin/LICENSE diff --git a/packages/plugin/README.md b/packages/plugin/README.md new file mode 100644 index 0000000..5550369 --- /dev/null +++ b/packages/plugin/README.md @@ -0,0 +1,55 @@ +# `hardhat-plugin-template` + +This is an example plugin that adds a task that prints a greeting. + +## Installation + +To install this plugin, run the following command: + +```bash +npm install --save-dev hardhat-plugin-template +``` + +In your `hardhat.config.ts` file, import the plugin and add it to the `plugins` array: + +```ts +import hardhatPluginTemplate from "hardhat-plugin-template"; + +export default { + plugins: [hardhatPluginTemplate], +}; +``` + +## Usage + +The plugin adds a new task called `my-task`. To run it, use the following command: + +```bash +npx hardhat my-task +``` + +You should see the following output: + +``` +Hello, Hardhat! +``` + +### Configuration + +You can configure the greeting that's printed by using the `myConfig` field in your Hardhat config. For example, you can have the following configuration: + +```ts +import hardhatPluginTemplate from "hardhat-plugin-template"; + +export default { + plugins: [hardhatPluginTemplate], + myConfig: { + greeting: "Hola", + }, + //... +}; +``` + +### Network logs + +This plugin also adds some example code to log different network events. To see it in action, all you need to do is run your Hardhat tests, deployment, or a script. diff --git a/eslint.config.js b/packages/plugin/eslint.config.js similarity index 76% rename from eslint.config.js rename to packages/plugin/eslint.config.js index 91efc02..17c5e28 100644 --- a/eslint.config.js +++ b/packages/plugin/eslint.config.js @@ -29,6 +29,14 @@ export default defineConfig( }, eslint.configs.recommended, tseslint.configs.recommendedTypeChecked, + { + files: ["src/**/*.ts", "test/**/*.ts", "integration-tests/**/*.ts"], + rules: { + // Disable two rules that conflict with the patterns that we use + "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-redundant-type-constituents": "off", + }, + }, { files: ["src/**/*.ts"], rules: { @@ -70,4 +78,12 @@ export default defineConfig( ], }, }, + { + // This is a set of more opinionated rules. Feel free to adapt to your style. + files: ["src/**/*.ts", "test/**/*.ts", "integration-tests/**/*.ts"], + ignores: ["test/**/fixture-projects/**"], + rules: { + "import/order": "error", + }, + }, ); diff --git a/packages/plugin/package.json b/packages/plugin/package.json new file mode 100644 index 0000000..27fc19d --- /dev/null +++ b/packages/plugin/package.json @@ -0,0 +1,59 @@ +{ + "name": "hardhat-plugin-template", + "version": "1.0.0", + "description": "Hardhat 3 plugin template", + "license": "MIT", + "type": "module", + "types": "dist/src/index.d.ts", + "exports": { + ".": "./dist/src/index.js" + }, + "keywords": [ + "ethereum", + "smart-contracts", + "hardhat", + "hardhat3", + "hardhat-plugin" + ], + "scripts": { + "lint": "pnpm prettier --check && pnpm eslint", + "lint:fix": "pnpm prettier --write && pnpm eslint --fix", + "eslint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "prettier": "prettier \"**/*.{ts,js,md,json}\"", + "test": "node --import tsx/esm --test --test-reporter=@nomicfoundation/hardhat-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", + "test:only": "node --import tsx/esm --test --test-only --test-reporter=@nomicfoundation/hardhat-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", + "test:coverage": "c8 --reporter html --reporter text --all --exclude test --exclude \"src/**/{types,type-extensions}.ts\" --src src node --import tsx/esm --test --test-reporter=@nomicfoundation/hardhat-node-test-reporter \"test/*.ts\" \"test/!(fixture-projects|helpers)/**/*.ts\"", + "pretest": "pnpm build", + "pretest:only": "pnpm build", + "build": "tsc --build .", + "prepublishOnly": "pnpm build", + "clean": "rimraf dist", + "watch": "tsc --build . --watch" + }, + "files": [ + "dist/src/", + "src/", + "CHANGELOG.md", + "LICENSE", + "README.md" + ], + "devDependencies": { + "@eslint/js": "^9.35.0", + "@nomicfoundation/hardhat-node-test-reporter": "^3.0.0", + "@tsconfig/node22": "^22.0.2", + "@types/node": "^22.11.0", + "c8": "^9.1.0", + "eslint": "^9.35.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "hardhat": "^3.0.6", + "prettier": "3.2.5", + "rimraf": "^5.0.5", + "tsx": "^4.19.3", + "typescript": "~5.8.0", + "typescript-eslint": "^8.43.0" + }, + "peerDependencies": { + "hardhat": "^3.0.6" + } +} diff --git a/packages/plugin/src/config.ts b/packages/plugin/src/config.ts new file mode 100644 index 0000000..9014146 --- /dev/null +++ b/packages/plugin/src/config.ts @@ -0,0 +1,69 @@ +import { HardhatUserConfig } from "hardhat/config"; +import { HardhatConfig } from "hardhat/types/config"; +import { HardhatUserConfigValidationError } from "hardhat/types/hooks"; + +/** + * This function validates the parts of the HardhatUserConfig that are relevant + * to the plugin. + * + * This function is called from the `validateUserConfig` hook handler. + * + * @param userConfig The HardhatUserConfig, as exported in the config file. + * @returns An array of validation errors, or an empty array if valid. + */ +export async function validatePluginConfig( + userConfig: HardhatUserConfig, +): Promise { + if (userConfig.myConfig === undefined) { + return []; + } + + if (typeof userConfig.myConfig !== "object") { + return [ + { + path: ["myConfig"], + message: "Expected an object with an optional greeting.", + }, + ]; + } + + const greeting = userConfig.myConfig?.greeting; + if (greeting === undefined) { + return []; + } + + if (typeof greeting !== "string" || greeting.length === 0) { + return [ + { + path: ["myConfig", "greeting"], + message: "Expected a non-empty string.", + }, + ]; + } + + return []; +} + +/** + * Resolves the plugin config, based on an already validated HardhatUserConfig, + * and a partially resolved HardhatConfig. + * + * This function is called from the `resolveUserConfig` hook handler. + * + * @param userConfig The HardhatUserConfig. + * @param partiallyResolvedConfig The partially resolved HardhatConfig, which is + * generated by calling `next` in the `validateUserConfig` hook handler. + * @returns The resolved HardhatConfig. + */ +export async function resolvePluginConfig( + userConfig: HardhatUserConfig, + partiallyResolvedConfig: HardhatConfig, +): Promise { + const greeting = userConfig.myConfig?.greeting ?? "Hello"; + const myConfig = { greeting }; + + return { + ...partiallyResolvedConfig, + myConfig, + }; +} diff --git a/packages/plugin/src/hooks/config.ts b/packages/plugin/src/hooks/config.ts new file mode 100644 index 0000000..9beb1b3 --- /dev/null +++ b/packages/plugin/src/hooks/config.ts @@ -0,0 +1,20 @@ +import type { ConfigHooks } from "hardhat/types/hooks"; +import { resolvePluginConfig, validatePluginConfig } from "../config.js"; + +export default async (): Promise> => { + const handlers: Partial = { + async validateUserConfig(userConfig) { + return validatePluginConfig(userConfig); + }, + async resolveUserConfig(userConfig, resolveConfigurationVariable, next) { + const partiallyResolvedConfig = await next( + userConfig, + resolveConfigurationVariable, + ); + + return resolvePluginConfig(userConfig, partiallyResolvedConfig); + }, + }; + + return handlers; +}; diff --git a/src/hooks/network.ts b/packages/plugin/src/hooks/network.ts similarity index 63% rename from src/hooks/network.ts rename to packages/plugin/src/hooks/network.ts index e06d959..28f7fa0 100644 --- a/src/hooks/network.ts +++ b/packages/plugin/src/hooks/network.ts @@ -2,6 +2,10 @@ import type { HookContext, NetworkHooks } from "hardhat/types/hooks"; import { ChainType, NetworkConnection } from "hardhat/types/network"; export default async (): Promise> => { + console.log( + "An instance of the HRE is using the network hooks for the first time", + ); + const handlers: Partial = { async newConnection( context: HookContext, @@ -15,6 +19,13 @@ export default async (): Promise> => { return connection; }, + async onRequest(context, networkConnection, jsonRpcRequest, next) { + console.log( + `Request from connection ${networkConnection.id} is being processed — Method: ${jsonRpcRequest.method}`, + ); + + return next(context, networkConnection, jsonRpcRequest); + }, }; return handlers; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts new file mode 100644 index 0000000..699dc8e --- /dev/null +++ b/packages/plugin/src/index.ts @@ -0,0 +1,26 @@ +import { task } from "hardhat/config"; +import { ArgumentType } from "hardhat/types/arguments"; +import type { HardhatPlugin } from "hardhat/types/plugins"; + +import "./type-extensions.js"; + +const plugin: HardhatPlugin = { + id: "hardhat-plugin-template", + hookHandlers: { + config: () => import("./hooks/config.js"), + network: () => import("./hooks/network.js"), + }, + tasks: [ + task("my-task", "Prints a greeting.") + .addOption({ + name: "who", + description: "Who is receiving the greeting.", + type: ArgumentType.STRING, + defaultValue: "Hardhat", + }) + .setAction(() => import("./tasks/my-task.js")) + .build(), + ], +}; + +export default plugin; diff --git a/packages/plugin/src/tasks/my-task.ts b/packages/plugin/src/tasks/my-task.ts new file mode 100644 index 0000000..b09a5b2 --- /dev/null +++ b/packages/plugin/src/tasks/my-task.ts @@ -0,0 +1,12 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types/hre"; + +interface MyTaskTaskArguments { + who: string; +} + +export default async function ( + taskArguments: MyTaskTaskArguments, + hre: HardhatRuntimeEnvironment, +) { + console.log(`${hre.config.myConfig.greeting}, ${taskArguments.who}!`); +} diff --git a/packages/plugin/src/type-extensions.ts b/packages/plugin/src/type-extensions.ts new file mode 100644 index 0000000..1888e77 --- /dev/null +++ b/packages/plugin/src/type-extensions.ts @@ -0,0 +1,12 @@ +import { MyPluginConfig, MyPluginUserConfig } from "./types.js"; + +import "hardhat/types/config"; +declare module "hardhat/types/config" { + interface HardhatUserConfig { + myConfig?: MyPluginUserConfig; + } + + interface HardhatConfig { + myConfig: MyPluginConfig; + } +} diff --git a/packages/plugin/src/types.ts b/packages/plugin/src/types.ts new file mode 100644 index 0000000..8b23adf --- /dev/null +++ b/packages/plugin/src/types.ts @@ -0,0 +1,7 @@ +export interface MyPluginUserConfig { + greeting?: string; +} + +export interface MyPluginConfig { + greeting: string; +} diff --git a/packages/plugin/test/config.ts b/packages/plugin/test/config.ts new file mode 100644 index 0000000..746b08d --- /dev/null +++ b/packages/plugin/test/config.ts @@ -0,0 +1,143 @@ +import { describe, it } from "node:test"; + +import assert from "node:assert/strict"; +import { HardhatConfig, HardhatUserConfig } from "hardhat/types/config"; +import { resolvePluginConfig, validatePluginConfig } from "../src/config.js"; +import { MyPluginUserConfig } from "../src/types.js"; + +describe("MyPlugin config", () => { + describe("Config validation", () => { + describe("Valid cases", () => { + it("Should consider an empty config as valid", async () => { + const validationErrors = await validatePluginConfig({}); + + assert.equal(validationErrors.length, 0); + }); + + it("Should ignore errors in other parts of the config", async () => { + const validationErrors = await validatePluginConfig({ + networks: { + foo: { + type: "http", + url: "INVALID URL", + }, + }, + }); + + assert.equal(validationErrors.length, 0); + }); + + it("Should accept an empty myConfig object", async () => { + const validationErrors = await validatePluginConfig({ + myConfig: {}, + }); + + assert.equal(validationErrors.length, 0); + }); + + it("Should accept an non-empty greeting", async () => { + const validationErrors = await validatePluginConfig({ + myConfig: { + greeting: "Hola", + }, + }); + + assert.equal(validationErrors.length, 0); + }); + }); + + describe("Invalid cases", () => { + // Many invalid cases are type-unsafe, as we have to trick TypeScript into + // allowing something that is invalid + it("Should reject a myConfig field with an invalid type", async () => { + const validationErrors = await validatePluginConfig({ + myConfig: "INVALID" as MyPluginUserConfig, + }); + + assert.deepEqual(validationErrors, [ + { + path: ["myConfig"], + message: "Expected an object with an optional greeting.", + }, + ]); + }); + + it("Should reject a myConfig field with an invalid greeting", async () => { + const validationErrors = await validatePluginConfig({ + myConfig: { + greeting: 123 as unknown as string, + }, + }); + + assert.deepEqual(validationErrors, [ + { + path: ["myConfig", "greeting"], + message: "Expected a non-empty string.", + }, + ]); + }); + + it("Should reject a myConfig field with an empty greeting", async () => { + const validationErrors = await validatePluginConfig({ + myConfig: { + greeting: "", + }, + }); + + assert.deepEqual(validationErrors, [ + { + path: ["myConfig", "greeting"], + message: "Expected a non-empty string.", + }, + ]); + }); + }); + }); + + describe("Config resolution", () => { + // The config resolution is always type-unsafe, as your plugin is extending + // the HardhatConfig type, but the partially resolved config isn't aware of + // your plugin's extensions. You are responsible for ensuring that they are + // defined correctly during the resolution process. + // + // We recommend testing using an artificial partially resolved config, as + // we do here, but taking care that the fields that your resolution logic + // depends on are defined and valid. + + it("Should resolve a config without a myConfig field", async () => { + const userConfig: HardhatUserConfig = {}; + const partiallyResolvedConfig = {} as HardhatConfig; + + const resolvedConfig = await resolvePluginConfig( + userConfig, + partiallyResolvedConfig, + ); + + assert.deepEqual(resolvedConfig.myConfig, { greeting: "Hello" }); + }); + + it("Should resolve a config with an empty myConfig field", async () => { + const userConfig: HardhatUserConfig = { myConfig: {} }; + const partiallyResolvedConfig = {} as HardhatConfig; + + const resolvedConfig = await resolvePluginConfig( + userConfig, + partiallyResolvedConfig, + ); + + assert.deepEqual(resolvedConfig.myConfig, { greeting: "Hello" }); + }); + + it("Should resolve a config using the provided greeting", async () => { + const userConfig: HardhatUserConfig = { myConfig: { greeting: "Hola" } }; + const partiallyResolvedConfig = {} as HardhatConfig; + + const resolvedConfig = await resolvePluginConfig( + userConfig, + partiallyResolvedConfig, + ); + + assert.deepEqual(resolvedConfig.myConfig, { greeting: "Hola" }); + }); + }); +}); diff --git a/packages/plugin/test/example-tests.ts b/packages/plugin/test/example-tests.ts new file mode 100644 index 0000000..395b156 --- /dev/null +++ b/packages/plugin/test/example-tests.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import path from "node:path"; +import { createHardhatRuntimeEnvironment } from "hardhat/hre"; +import MyPlugin from "../src/index.js"; +import { createFixtureProjectHRE } from "./helpers/fixture-projects.js"; + +describe("MyPlugin tests", () => { + describe("Test using a fixture project", async () => { + it("Should define my-task", async () => { + const hre = await createFixtureProjectHRE("base-project"); + + const myTask = hre.tasks.getTask("my-task"); + assert.notEqual( + myTask, + undefined, + "my-task should be defined because we loaded the plugin", + ); + + // You can use any feature of Hardhat to build your tests, for example, + // running the task and connecting to a new edr-simulated network + await myTask.run(); + + const conn = await hre.network.connect(); + assert.equal( + await conn.provider.request({ method: "eth_blockNumber" }), + "0x0", + "The simulated chain is new, so it should be empty", + ); + }); + }); + + describe("Test creating a new HRE with an inline config", async () => { + it("Should be able to load the plugin", async () => { + // You can also create a new HRE without a fixture project, including + // a custom config. + // + // In this case we don't provide a fixture project, nor a config path, just + // a config object. + // + // You can customize the config object here, including adding new plugins. + const hre = await createHardhatRuntimeEnvironment({ + plugins: [MyPlugin], + myConfig: { + greeting: "Hola", + }, + }); + + assert.equal(hre.config.myConfig.greeting, "Hola"); + + // The config path is undefined because we didn't provide it to + // createHardhatRuntimeEnvironment. See its documentation for more info. + assert.equal(hre.config.paths.config, undefined); + + // The root path is the directory containing the closest package.json to + // the CWD, if none is provided. + assert.equal( + hre.config.paths.root, + path.resolve(import.meta.dirname, ".."), + ); + }); + }); +}); diff --git a/packages/plugin/test/fixture-projects/base-project/hardhat.config.ts b/packages/plugin/test/fixture-projects/base-project/hardhat.config.ts new file mode 100644 index 0000000..6d2d235 --- /dev/null +++ b/packages/plugin/test/fixture-projects/base-project/hardhat.config.ts @@ -0,0 +1,8 @@ +import { HardhatUserConfig } from "hardhat/config"; +import MyPlugin from "../../../src/index.js"; + +const config: HardhatUserConfig = { + plugins: [MyPlugin], +}; + +export default config; diff --git a/packages/plugin/test/fixture-projects/base-project/package.json b/packages/plugin/test/fixture-projects/base-project/package.json new file mode 100644 index 0000000..fd91b66 --- /dev/null +++ b/packages/plugin/test/fixture-projects/base-project/package.json @@ -0,0 +1,6 @@ +{ + "name": "base-project", + "version": "1.0.0", + "type": "module", + "private": true +} diff --git a/packages/plugin/test/helpers/fixture-projects.ts b/packages/plugin/test/helpers/fixture-projects.ts new file mode 100644 index 0000000..4cef1cf --- /dev/null +++ b/packages/plugin/test/helpers/fixture-projects.ts @@ -0,0 +1,44 @@ +import path from "node:path"; + +import { + createHardhatRuntimeEnvironment, + importUserConfig, + resolveHardhatConfigPath, +} from "hardhat/hre"; +import { HardhatRuntimeEnvironment } from "hardhat/types/hre"; + +/** + * Creates a new Hardhat Runtime Environment based on a fixture project. + * + * A fixture project is a Hardhat project located in `../fixture-projects` that + * has its own hardhat.config.ts and package.json (not part of the monorepo). + * + * Note: This function doesn't modify the global environment, the global + * instance of the HRE, nor the CWD. + * + * @param fixtureProjectName The name of the fixture project to use. + * e.g. `base-project` + * @returns A new HRE with the fixture project folder as root. + */ +export async function createFixtureProjectHRE( + fixtureProjectName: string, +): Promise { + const fixtureProjectRoot = path.resolve( + import.meta.dirname, + `../fixture-projects/${fixtureProjectName}`, + ); + + const configPath = await resolveHardhatConfigPath( + path.join(fixtureProjectRoot, "hardhat.config.ts"), + ); + + const userConfig = await importUserConfig(configPath); + + return createHardhatRuntimeEnvironment( + userConfig, + { + config: configPath, + }, + fixtureProjectRoot, + ); +} diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json new file mode 100644 index 0000000..124103e --- /dev/null +++ b/packages/plugin/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "outDir": "${configDir}/dist", + "declaration": true, + "declarationMap": true, + "forceConsistentCasingInFileNames": true, + "noEmitOnError": true, + "noImplicitOverride": true, + "noUncheckedSideEffectImports": true, + "skipDefaultLibCheck": true, + "sourceMap": true, + "composite": true, + "incremental": true, + "isolatedModules": true, + "typeRoots": ["${configDir}/node_modules/@types"] + }, + "exclude": ["${configDir}/dist", "${configDir}/node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5276c1c..6207d67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,11 +6,30 @@ settings: importers: - .: - dependencies: + .: {} + + packages/example-project: + devDependencies: + '@tsconfig/node22': + specifier: ^22.0.2 + version: 22.0.2 + '@types/node': + specifier: ^22.11.0 + version: 22.18.4 + forge-std: + specifier: github:foundry-rs/forge-std#v1.9.4 + version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/1eea5bae12ae557d589f9f0f0edae2faa47cb262 hardhat: specifier: ^3.0.6 version: 3.0.6 + hardhat-plugin-template: + specifier: workspace:* + version: link:../plugin + typescript: + specifier: ~5.8.0 + version: 5.8.3 + + packages/plugin: devDependencies: '@eslint/js': specifier: ^9.35.0 @@ -22,8 +41,8 @@ importers: specifier: ^22.0.2 version: 22.0.2 '@types/node': - specifier: ^20.14.9 - version: 20.19.13 + specifier: ^22.11.0 + version: 22.18.4 c8: specifier: ^9.1.0 version: 9.1.0 @@ -36,9 +55,9 @@ importers: eslint-plugin-import: specifier: ^2.32.0 version: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.35.0) - expect-type: - specifier: ^1.2.1 - version: 1.2.2 + hardhat: + specifier: ^3.0.6 + version: 3.0.6 prettier: specifier: 3.2.5 version: 3.2.5 @@ -55,6 +74,8 @@ importers: specifier: ^8.43.0 version: 8.43.0(eslint@9.35.0)(typescript@5.8.3) + packages/plugin/test/fixture-projects/base-project: {} + packages: '@actions/core@1.11.1': @@ -314,8 +335,8 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.30': - resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -475,8 +496,8 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/node@20.19.13': - resolution: {integrity: sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==} + '@types/node@22.18.4': + resolution: {integrity: sha512-UJdblFqXymSBhmZf96BnbisoFIr8ooiiBRMolQgg77Ea+VM37jXw76C2LQr9n8wm9+i/OvlUlW6xSvqwzwqznw==} '@typescript-eslint/eslint-plugin@8.43.0': resolution: {integrity: sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==} @@ -982,10 +1003,6 @@ packages: ethereum-cryptography@2.2.1: resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} - engines: {node: '>=12.0.0'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1042,6 +1059,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/1eea5bae12ae557d589f9f0f0edae2faa47cb262: + resolution: {tarball: https://codeload.github.com/foundry-rs/forge-std/tar.gz/1eea5bae12ae557d589f9f0f0edae2faa47cb262} + version: 1.9.4 + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2002,7 +2023,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.30': + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 @@ -2178,7 +2199,7 @@ snapshots: '@types/json5@0.0.29': {} - '@types/node@20.19.13': + '@types/node@22.18.4': dependencies: undici-types: 6.21.0 @@ -2822,8 +2843,6 @@ snapshots: '@scure/bip32': 1.4.0 '@scure/bip39': 1.3.0 - expect-type@1.2.2: {} - fast-deep-equal@3.1.3: {} fast-equals@5.2.2: {} @@ -2877,6 +2896,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/1eea5bae12ae557d589f9f0f0edae2faa47cb262: {} + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -3677,7 +3698,7 @@ snapshots: v8-to-istanbul@9.3.0: dependencies: - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..9c9be4b --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/**" diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index a23c017..0000000 --- a/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { HardhatPlugin } from "hardhat/types/plugins"; - -export default { - id: "hardhat-plugin-template", - - // The `npmPackage` field is only necessary if the `id` is not the npm package - // name. This is useful when shipping multiple npm plugins in the same - // package. - // npmPackage: "hardhat-plugin-template", - - hookHandlers: { - network: () => import("./hooks/network.js"), - }, -} satisfies HardhatPlugin; diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index 8ddb860..0000000 --- a/test/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -// import assert from "node:assert/strict"; -// import { describe, it } from "node:test"; - -// describe("Example tests", () => { -// it("foo", function () { -// assert.equal(foo(), "foo"); -// }); - -// it("bar", function () { -// assert.equal(bar(), "bar"); -// }); - -// it("foobar", function () { -// assert.equal(foobar(), "foobar"); -// }); - -// it("should return the right types", function () { -// expectTypeOf(foo()).toMatchTypeOf(); -// }); -// });