diff --git a/v-next/example-project/hardhat.config.ts b/v-next/example-project/hardhat.config.ts index dd05884363..1fe96be52f 100644 --- a/v-next/example-project/hardhat.config.ts +++ b/v-next/example-project/hardhat.config.ts @@ -95,6 +95,29 @@ const greeting = task("hello", "Prints a greeting") }) .build(); +const pluginExample = { + id: "community-plugin", + tasks: [ + task("plugin-hello", "Prints a greeting from community-plugin") + .addOption({ + name: "greeting", + description: "The greeting to print", + defaultValue: "Hello, World from community-plugin!", + }) + .setAction(async ({ greeting }, _) => { + console.log(greeting); + + if (greeting === "") { + throw new HardhatPluginError( + "community-plugin", + "Greeting cannot be empty", + ); + } + }) + .build(), + ], +}; + const config: HardhatUserConfig = { tasks: [ exampleTaskOverride, @@ -105,30 +128,7 @@ const config: HardhatUserConfig = { exampleEmptySubtask, greeting, ], - plugins: [ - { - id: "plugin-example", - tasks: [ - task("plugin1-hello", "Prints a greeting from plugin1") - .addOption({ - name: "greeting", - description: "The greeting to print", - defaultValue: "Hello, World from plugin1!", - }) - .setAction(async ({ greeting }, _) => { - console.log(greeting); - - if (greeting === "") { - throw new HardhatPluginError( - "plugin-example", - "Greeting cannot be empty", - ); - } - }) - .build(), - ], - }, - ], + plugins: [pluginExample], privateKey: configVariable("privateKey"), }; diff --git a/v-next/hardhat-errors/src/errors.ts b/v-next/hardhat-errors/src/errors.ts index 3ecba54642..0f7740f974 100644 --- a/v-next/hardhat-errors/src/errors.ts +++ b/v-next/hardhat-errors/src/errors.ts @@ -47,7 +47,7 @@ const IS_HARDHAT_PLUGIN_ERROR_PROPERTY_NAME = "_isHardhatPluginError"; * `HardhatPluginError`. */ export class HardhatError< - ErrorDescriptorT extends ErrorDescriptor, + ErrorDescriptorT extends ErrorDescriptor = ErrorDescriptor, > extends CustomError { public static readonly ERRORS: typeof ERRORS = ERRORS; @@ -57,6 +57,10 @@ export class HardhatError< ErrorDescriptorT["messageTemplate"] >; + readonly #errorCode: string; + + readonly #formattedMessage: string; + constructor( ...[ errorDescriptor, @@ -64,7 +68,7 @@ export class HardhatError< parentError, ]: HardhatErrorConstructorArguments ) { - const prefix = `${getErrorCode(errorDescriptor)}: `; + const errorCode = getErrorCode(errorDescriptor); const formattedMessage = messageArgumentsOrParentError === undefined || @@ -76,7 +80,7 @@ export class HardhatError< ); super( - prefix + formattedMessage, + `${errorCode}: ${formattedMessage}`, parentError instanceof Error ? parentError : messageArgumentsOrParentError instanceof Error @@ -85,6 +89,8 @@ export class HardhatError< ); this.#descriptor = errorDescriptor; + this.#errorCode = errorCode; + this.#formattedMessage = formattedMessage; if ( messageArgumentsOrParentError === undefined || @@ -160,6 +166,14 @@ export class HardhatError< > { return this.#arguments; } + + public get errorCode(): string { + return this.#errorCode; + } + + public get formattedMessage(): string { + return this.#formattedMessage; + } } /** @@ -168,7 +182,7 @@ export class HardhatError< */ export class HardhatPluginError extends CustomError { constructor( - public readonly pluginName: string, + public readonly pluginId: string, message: string, parentError?: Error, ) { diff --git a/v-next/hardhat-errors/test/errors.ts b/v-next/hardhat-errors/test/errors.ts index a9954d05c1..02b1ea849e 100644 --- a/v-next/hardhat-errors/test/errors.ts +++ b/v-next/hardhat-errors/test/errors.ts @@ -203,7 +203,7 @@ describe("HardhatPluginError", () => { describe("Without parent error", () => { it("should have the right plugin name", () => { const error = new HardhatPluginError("examplePlugin", "error message"); - assert.equal(error.pluginName, "examplePlugin"); + assert.equal(error.pluginId, "examplePlugin"); }); it("should have the right error message", () => { diff --git a/v-next/hardhat/src/cli.ts b/v-next/hardhat/src/cli.ts index 235b0664a3..9fe1d0438f 100644 --- a/v-next/hardhat/src/cli.ts +++ b/v-next/hardhat/src/cli.ts @@ -5,7 +5,6 @@ process.setSourceMapsEnabled(true); // eslint-disable-next-line no-restricted-syntax -- Allow top-level await here const { main } = await import("./internal/cli/main.js"); -main(process.argv.slice(2)).catch((error: unknown) => { - console.error(error); +main(process.argv.slice(2)).catch(() => { process.exitCode = 1; }); diff --git a/v-next/hardhat/src/internal/cli/error-handler.ts b/v-next/hardhat/src/internal/cli/error-handler.ts new file mode 100644 index 0000000000..d7bba907b6 --- /dev/null +++ b/v-next/hardhat/src/internal/cli/error-handler.ts @@ -0,0 +1,153 @@ +import util from "node:util"; + +import { + HardhatError, + HardhatPluginError, +} from "@ignored/hardhat-vnext-errors"; +import chalk from "chalk"; + +import { HARDHAT_NAME, HARDHAT_WEBSITE_URL } from "../constants.js"; + +/** + * The different categories of errors that can be handled by hardhat cli. + * Each category has a different way of being formatted and displayed. + * To add new categories, add a new entry to this enum and update the + * {@link getErrorWithCategory} and {@link getErrorMessages} functions + * accordingly. + */ +enum ErrorCategory { + HARDHAT = "HARDHAT", + PLUGIN = "PLUGIN", + COMMUNITY_PLUGIN = "COMMUNITY_PLUGIN", + OTHER = "OTHER", +} + +type ErrorWithCategory = + | { + category: ErrorCategory.HARDHAT; + categorizedError: HardhatError; + } + | { + category: ErrorCategory.PLUGIN; + categorizedError: HardhatError; + } + | { + category: ErrorCategory.COMMUNITY_PLUGIN; + categorizedError: HardhatPluginError; + } + | { + category: ErrorCategory.OTHER; + categorizedError: unknown; + }; + +/** + * The different messages that can be displayed for each category of errors. + * - `formattedErrorMessage`: the main error message that is always displayed. + * - `showMoreInfoMessage`: an optional message that can be displayed to + * provide more information about the error. It is only displayed when stack + * traces are hidden. + * - `postErrorStackTraceMessage` an optional message that can be displayed + * after the stack trace. It is only displayed when stack traces are shown. + */ +interface ErrorMessages { + formattedErrorMessage: string; + showMoreInfoMessage?: string; + postErrorStackTraceMessage?: string; +} + +/** + * Formats and logs error messages based on the category the error belongs to. + * + * @param error the error to handle. Supported categories are defined in + * {@link ErrorCategory}. + * @param shouldShowStackTraces whether to show stack traces or not. If true, + * the stack trace is always shown. If false, the stack trace is only shown for + * errors of category {@link ErrorCategory.OTHER}. + * @param print the function used to print the error message, defaults to + * `console.error`. Useful for testing to capture error messages. + */ +export function printErrorMessages( + error: unknown, + shouldShowStackTraces: boolean = false, + print: (message: string) => void = console.error, +): void { + const showStackTraces = + shouldShowStackTraces || + getErrorWithCategory(error).category === ErrorCategory.OTHER; + const { + formattedErrorMessage, + showMoreInfoMessage, + postErrorStackTraceMessage, + } = getErrorMessages(error); + + print(formattedErrorMessage); + + print(""); + + if (showStackTraces) { + /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions + -- As we don't know the type of error we are printing, we can't know if it + has a `stack` property or not, so we print it as a string. */ + print(error instanceof Error ? `${error.stack}` : `${util.inspect(error)}`); + if (postErrorStackTraceMessage !== undefined) { + print(""); + print(postErrorStackTraceMessage); + } + } else if (showMoreInfoMessage !== undefined) { + print(showMoreInfoMessage); + } +} + +function getErrorWithCategory(error: unknown): ErrorWithCategory { + if (HardhatError.isHardhatError(error)) { + if (error.pluginId === undefined) { + return { + category: ErrorCategory.HARDHAT, + categorizedError: error, + }; + } else { + return { + category: ErrorCategory.PLUGIN, + categorizedError: error, + }; + } + } + + if (HardhatPluginError.isHardhatPluginError(error)) { + return { + category: ErrorCategory.COMMUNITY_PLUGIN, + categorizedError: error, + }; + } + + return { + category: ErrorCategory.OTHER, + categorizedError: error, + }; +} + +function getErrorMessages(error: unknown): ErrorMessages { + const { category, categorizedError } = getErrorWithCategory(error); + switch (category) { + case ErrorCategory.HARDHAT: + return { + formattedErrorMessage: `${chalk.red.bold(`Error ${categorizedError.errorCode}:`)} ${categorizedError.formattedMessage}`, + showMoreInfoMessage: `For more info go to ${HARDHAT_WEBSITE_URL}${categorizedError.errorCode} or run ${HARDHAT_NAME} with --show-stack-traces`, + }; + case ErrorCategory.PLUGIN: + return { + formattedErrorMessage: `${chalk.red.bold(`Error ${categorizedError.errorCode} in plugin ${categorizedError.pluginId}:`)} ${categorizedError.formattedMessage}`, + showMoreInfoMessage: `For more info go to ${HARDHAT_WEBSITE_URL}${categorizedError.errorCode} or run ${HARDHAT_NAME} with --show-stack-traces`, + }; + case ErrorCategory.COMMUNITY_PLUGIN: + return { + formattedErrorMessage: `${chalk.red.bold(`Error in community plugin ${categorizedError.pluginId}:`)} ${categorizedError.message}`, + showMoreInfoMessage: `For more info run ${HARDHAT_NAME} with --show-stack-traces`, + }; + case ErrorCategory.OTHER: + return { + formattedErrorMessage: chalk.red.bold(`An unexpected error occurred:`), + postErrorStackTraceMessage: `If you think this is a bug in Hardhat, please report it here: ${HARDHAT_WEBSITE_URL}report-bug`, + }; + } +} diff --git a/v-next/hardhat/src/internal/cli/main.ts b/v-next/hardhat/src/internal/cli/main.ts index 62544e53be..3dd2f7fec5 100644 --- a/v-next/hardhat/src/internal/cli/main.ts +++ b/v-next/hardhat/src/internal/cli/main.ts @@ -31,6 +31,7 @@ import { builtinPlugins } from "../builtin-plugins/index.js"; import { importUserConfig } from "../helpers/config-loading.js"; import { getHardhatRuntimeEnvironmentSingleton } from "../hre-singleton.js"; +import { printErrorMessages } from "./error-handler.js"; import { getGlobalHelpString } from "./helpers/getGlobalHelpString.js"; import { getHelpString } from "./helpers/getHelpString.js"; import { initHardhat } from "./init/init.js"; @@ -40,28 +41,30 @@ export async function main( cliArguments: string[], print: (message: string) => void = console.log, ): Promise { - const usedCliArguments: boolean[] = new Array(cliArguments.length).fill( - false, - ); + let hardhatSpecialArgs; - const hardhatSpecialArgs = await parseHardhatSpecialArguments( - cliArguments, - usedCliArguments, - ); + try { + const usedCliArguments: boolean[] = new Array(cliArguments.length).fill( + false, + ); - if (hardhatSpecialArgs.version) { - return printVersionMessage(print); - } + hardhatSpecialArgs = await parseHardhatSpecialArguments( + cliArguments, + usedCliArguments, + ); - if (hardhatSpecialArgs.init) { - return initHardhat(); - } + if (hardhatSpecialArgs.version) { + return await printVersionMessage(print); + } - if (hardhatSpecialArgs.configPath === undefined) { - hardhatSpecialArgs.configPath = await resolveHardhatConfigPath(); - } + if (hardhatSpecialArgs.init) { + return await initHardhat(); + } + + if (hardhatSpecialArgs.configPath === undefined) { + hardhatSpecialArgs.configPath = await resolveHardhatConfigPath(); + } - try { const userConfig = await importUserConfig(hardhatSpecialArgs.configPath); const configPlugins = Array.isArray(userConfig.plugins) @@ -122,21 +125,7 @@ export async function main( await task.run(taskArguments); } catch (error) { - process.exitCode = 1; - - // TODO: Use ensureError - if (!(error instanceof Error)) { - throw error; - } - - // TODO: Print the errors nicely, especially `HardhatError`s. - - print(`Error running the task: ${error.message}`); - - if (hardhatSpecialArgs.showStackTraces) { - print(""); - console.error(error); - } + printErrorMessages(error, hardhatSpecialArgs?.showStackTraces); } } diff --git a/v-next/hardhat/src/internal/constants.ts b/v-next/hardhat/src/internal/constants.ts new file mode 100644 index 0000000000..7bfebfb6bf --- /dev/null +++ b/v-next/hardhat/src/internal/constants.ts @@ -0,0 +1,2 @@ +export const HARDHAT_NAME = "Hardhat"; +export const HARDHAT_WEBSITE_URL = "https://hardhat.org/"; diff --git a/v-next/hardhat/test/internal/cli/error-handler.ts b/v-next/hardhat/test/internal/cli/error-handler.ts new file mode 100644 index 0000000000..0b99228842 --- /dev/null +++ b/v-next/hardhat/test/internal/cli/error-handler.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import util from "node:util"; + +import { + HardhatError, + HardhatPluginError, +} from "@ignored/hardhat-vnext-errors"; +import chalk from "chalk"; + +import { printErrorMessages } from "../../../src/internal/cli/error-handler.js"; +import { + HARDHAT_NAME, + HARDHAT_WEBSITE_URL, +} from "../../../src/internal/constants.js"; + +const mockErrorDescriptor = { + number: 123, + messageTemplate: "error message", + websiteTitle: "Mock error", + websiteDescription: "This is a mock error", +} as const; + +describe("error-handler", () => { + describe("printErrorMessages", () => { + describe("with a Hardhat error", () => { + it("should print the error message", () => { + const lines: string[] = []; + const error = new HardhatError(mockErrorDescriptor); + + printErrorMessages(error, false, (msg: string) => { + lines.push(msg); + }); + + assert.equal(lines.length, 3); + assert.equal( + lines[0], + `${chalk.red.bold(`Error ${error.errorCode}:`)} ${error.formattedMessage}`, + ); + assert.equal(lines[1], ""); + assert.equal( + lines[2], + `For more info go to ${HARDHAT_WEBSITE_URL}${error.errorCode} or run ${HARDHAT_NAME} with --show-stack-traces`, + ); + }); + + it("should print the stack trace", () => { + const lines: string[] = []; + const error = new HardhatError(mockErrorDescriptor); + + printErrorMessages(error, true, (msg: string) => { + lines.push(msg); + }); + + assert.equal(lines.length, 3); + assert.equal( + lines[0], + `${chalk.red.bold(`Error ${error.errorCode}:`)} ${error.formattedMessage}`, + ); + assert.equal(lines[1], ""); + assert.equal(lines[2], `${error.stack}`); + }); + }); + + describe("with a Hardhat plugin error", () => { + it("should print the error message", () => { + const lines: string[] = []; + const error = new HardhatError({ + pluginId: "example-plugin", + ...mockErrorDescriptor, + }); + + printErrorMessages(error, false, (msg: string) => { + lines.push(msg); + }); + + assert.equal(lines.length, 3); + assert.equal( + lines[0], + `${chalk.red.bold(`Error ${error.errorCode} in plugin ${error.pluginId}:`)} ${error.formattedMessage}`, + ); + assert.equal(lines[1], ""); + assert.equal( + lines[2], + `For more info go to ${HARDHAT_WEBSITE_URL}${error.errorCode} or run ${HARDHAT_NAME} with --show-stack-traces`, + ); + }); + + it("should print the stack trace", () => { + const lines: string[] = []; + const error = new HardhatError({ + pluginId: "example-plugin", + ...mockErrorDescriptor, + }); + + printErrorMessages(error, true, (msg: string) => { + lines.push(msg); + }); + + assert.equal(lines.length, 3); + assert.equal( + lines[0], + `${chalk.red.bold(`Error ${error.errorCode} in plugin ${error.pluginId}:`)} ${error.formattedMessage}`, + ); + assert.equal(lines[1], ""); + assert.equal(lines[2], `${error.stack}`); + }); + }); + + describe("with a Hardhat community plugin error", () => { + it("should print the error message", () => { + const lines: string[] = []; + const error = new HardhatPluginError( + "community-plugin", + "error message", + ); + + printErrorMessages(error, false, (msg: string) => { + lines.push(msg); + }); + + assert.equal(lines.length, 3); + assert.equal( + lines[0], + `${chalk.red.bold(`Error in community plugin ${error.pluginId}:`)} ${error.message}`, + ); + assert.equal(lines[1], ""); + assert.equal( + lines[2], + `For more info run ${HARDHAT_NAME} with --show-stack-traces`, + ); + }); + + it("should print the stack trace", () => { + const lines: string[] = []; + const error = new HardhatPluginError( + "community-plugin", + "error message", + ); + + printErrorMessages(error, true, (msg: string) => { + lines.push(msg); + }); + + assert.equal(lines.length, 3); + assert.equal( + lines[0], + `${chalk.red.bold(`Error in community plugin ${error.pluginId}:`)} ${error.message}`, + ); + assert.equal(lines[1], ""); + assert.equal(lines[2], `${error.stack}`); + }); + }); + + describe("with an unknown error", () => { + it("should print the error message with the stack traces for an instance of Error", () => { + const lines: string[] = []; + const error = new Error("error message"); + + printErrorMessages(error, false, (msg: string) => { + lines.push(msg); + }); + + assert.equal(lines.length, 5); + assert.equal(lines[0], chalk.red.bold(`An unexpected error occurred:`)); + assert.equal(lines[1], ""); + assert.equal(lines[2], `${error.stack}`); + assert.equal(lines[3], ""); + assert.equal( + lines[4], + `If you think this is a bug in Hardhat, please report it here: ${HARDHAT_WEBSITE_URL}report-bug`, + ); + }); + + it("should print the error message with the error for an unknown error", () => { + const lines: string[] = []; + const error = { message: "error message" }; + + printErrorMessages(error, false, (msg: string) => { + lines.push(msg); + }); + + assert.equal(lines.length, 5); + assert.equal(lines[0], chalk.red.bold(`An unexpected error occurred:`)); + assert.equal(lines[1], ""); + assert.equal(lines[2], `${util.inspect(error)}`); + assert.equal(lines[3], ""); + assert.equal( + lines[4], + `If you think this is a bug in Hardhat, please report it here: ${HARDHAT_WEBSITE_URL}report-bug`, + ); + }); + }); + }); +}); diff --git a/v-next/hardhat/test/internal/cli/main.ts b/v-next/hardhat/test/internal/cli/main.ts index cde65910c9..a73662bca7 100644 --- a/v-next/hardhat/test/internal/cli/main.ts +++ b/v-next/hardhat/test/internal/cli/main.ts @@ -108,21 +108,6 @@ describe("main", function () { }); }); - describe("show-stack-traces", function () { - useFixtureProject("cli/parsing/base-project"); - - // TODO: implement as soon as the 'pretty print error' and 'show-stack-traces task' are done - // This test throws when a task is not recognized - it.todo("should show the stack traces for the error", async function () { - const command = "npx hardhat non-existing-task"; - const cliArguments = command.split(" ").slice(2); - - await main(cliArguments); - assert.equal(process.exitCode, 1); // Expect 1 because the task failed - process.exitCode = 0; // Reset the exit code so it does not affect other tests - }); - }); - describe("different configuration file path", function () { useFixtureProject("cli/parsing/user-config");