Skip to content

Commit

Permalink
Merge pull request #5870 from NomicFoundation/feat/solidity-test-comp…
Browse files Browse the repository at this point in the history
…ilation

feat: define solidity test sources in config
  • Loading branch information
galargh authored Jan 13, 2025
2 parents 047919c + 1277665 commit 34e9feb
Show file tree
Hide file tree
Showing 15 changed files with 196 additions and 268 deletions.
1 change: 1 addition & 0 deletions v-next/example-project/hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const config: HardhatUserConfig = {
tests: {
mocha: "test/mocha",
nodeTest: "test/node",
solidity: "test/contracts",
},
},
solidity: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import type { HardhatUserConfig } from "../../../config.js";
import type { HardhatConfig } from "../../../types/config.js";
import type { HardhatUserConfigValidationError } from "../../../types/hooks.js";

import { isObject } from "@ignored/hardhat-vnext-utils/lang";
import { resolveFromRoot } from "@ignored/hardhat-vnext-utils/path";
import {
conditionalUnionType,
unionType,
validateUserConfigZodType,
} from "@ignored/hardhat-vnext-zod-utils";
Expand Down Expand Up @@ -89,6 +92,17 @@ const solidityTestUserConfigType = z.object({
});

const userConfigType = z.object({
paths: z
.object({
test: conditionalUnionType(
[
[isObject, z.object({ solidity: z.string().optional() })],
[(data) => typeof data === "string", z.string()],
],
"Expected a string or an object with an optional 'solidity' property",
).optional(),
})
.optional(),
solidityTest: solidityTestUserConfigType.optional(),
});

Expand All @@ -102,8 +116,21 @@ export async function resolveSolidityTestUserConfig(
userConfig: HardhatUserConfig,
resolvedConfig: HardhatConfig,
): Promise<HardhatConfig> {
let testsPath = userConfig.paths?.tests;

// TODO: use isObject when the type narrowing issue is fixed
testsPath = typeof testsPath === "object" ? testsPath.solidity : testsPath;
testsPath ??= "test";

return {
...resolvedConfig,
paths: {
...resolvedConfig.paths,
tests: {
...resolvedConfig.paths.tests,
solidity: resolveFromRoot(resolvedConfig.paths.root, testsPath),
},
},
solidityTest: userConfig.solidityTest ?? {},
};
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { RunOptions } from "./runner.js";
import type { ArtifactsManager } from "../../../types/artifacts.js";
import type { SolidityTestConfig } from "../../../types/config.js";
import type {
Artifact,
SolidityTestRunnerConfigArgs,
CachedChains,
CachedEndpoints,
Expand All @@ -11,10 +9,7 @@ import type {
AddressLabel,
} from "@ignored/edr";

import { HardhatError } from "@ignored/hardhat-vnext-errors";
import { exists } from "@ignored/hardhat-vnext-utils/fs";
import { hexStringToBytes } from "@ignored/hardhat-vnext-utils/hex";
import { resolveFromRoot } from "@ignored/hardhat-vnext-utils/path";

function hexStringToBuffer(hexString: string): Buffer {
return Buffer.from(hexStringToBytes(hexString));
Expand All @@ -31,9 +26,9 @@ export function solidityTestConfigToSolidityTestRunnerConfigArgs(
config: SolidityTestConfig,
): SolidityTestRunnerConfigArgs {
const fsPermissions: PathPermission[] | undefined = [
config.fsPermissions?.readWrite?.map((path) => ({ access: 0, path })) ?? [],
config.fsPermissions?.read?.map((path) => ({ access: 0, path })) ?? [],
config.fsPermissions?.write?.map((path) => ({ access: 0, path })) ?? [],
config.fsPermissions?.readWrite?.map((p) => ({ access: 0, path: p })) ?? [],
config.fsPermissions?.read?.map((p) => ({ access: 0, path: p })) ?? [],
config.fsPermissions?.write?.map((p) => ({ access: 0, path: p })) ?? [],
].flat(1);

const labels: AddressLabel[] | undefined = config.labels?.map(
Expand Down Expand Up @@ -103,63 +98,3 @@ export function solidityTestConfigToSolidityTestRunnerConfigArgs(
rpcStorageCaching,
};
}

export async function getArtifacts(
hardhatArtifacts: ArtifactsManager,
): Promise<Artifact[]> {
const fqns = await hardhatArtifacts.getAllFullyQualifiedNames();
const artifacts: Artifact[] = [];

for (const fqn of fqns) {
const hardhatArtifact = await hardhatArtifacts.readArtifact(fqn);
const buildInfo = await hardhatArtifacts.getBuildInfo(fqn);

if (buildInfo === undefined) {
throw new HardhatError(
HardhatError.ERRORS.SOLIDITY_TESTS.BUILD_INFO_NOT_FOUND_FOR_CONTRACT,
{
fqn,
},
);
}

const id = {
name: hardhatArtifact.contractName,
solcVersion: buildInfo.solcVersion,
source: hardhatArtifact.sourceName,
};

const contract = {
abi: JSON.stringify(hardhatArtifact.abi),
bytecode: hardhatArtifact.bytecode,
deployedBytecode: hardhatArtifact.deployedBytecode,
};

const artifact = { id, contract };
artifacts.push(artifact);
}

return artifacts;
}

export async function isTestArtifact(
root: string,
artifact: Artifact,
): Promise<boolean> {
const { source } = artifact.id;

if (!source.endsWith(".t.sol")) {
return false;
}

// NOTE: We also check whether the file exists in the workspace to filter out
// the artifacts from node modules.
const sourcePath = resolveFromRoot(root, source);
const sourceExists = await exists(sourcePath);

if (!sourceExists) {
return false;
}

return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ const hardhatPlugin: HardhatPlugin = {
tasks: [
task(["test", "solidity"], "Run the Solidity tests")
.setAction(import.meta.resolve("./task-action.js"))
.addFlag({
name: "noCompile",
description: "Don't compile the project before running the tests",
})
.build(),
],
dependencies: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,48 +1,62 @@
import type { RunOptions } from "./runner.js";
import type { TestEvent } from "./types.js";
import type { BuildOptions } from "../../../types/solidity/build-system.js";
import type { NewTaskActionFunction } from "../../../types/tasks.js";
import type { SolidityTestRunnerConfigArgs } from "@ignored/edr";

import { finished } from "node:stream/promises";

import { getAllFilesMatching } from "@ignored/hardhat-vnext-utils/fs";
import { createNonClosingWriter } from "@ignored/hardhat-vnext-utils/stream";

import { shouldMergeCompilationJobs } from "../solidity/build-profiles.js";
import {
getArtifacts,
isTestArtifact,
throwIfSolidityBuildFailed,
} from "../solidity/build-results.js";

import {
solidityTestConfigToRunOptions,
solidityTestConfigToSolidityTestRunnerConfigArgs,
} from "./helpers.js";
import { testReporter } from "./reporter.js";
import { run } from "./runner.js";

interface TestActionArguments {
noCompile: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface -- the interface is expected to be expanded in the future
interface TestActionArguments {}

const runSolidityTests: NewTaskActionFunction<TestActionArguments> = async (
{ noCompile },
{},
hre,
) => {
if (!noCompile) {
await hre.tasks.getTask("compile").run({});
console.log();
}

const artifacts = await getArtifacts(hre.artifacts);
const testSuiteIds = (
await Promise.all(
artifacts.map(async (artifact) => {
if (await isTestArtifact(hre.config.paths.root, artifact)) {
return artifact.id;
}
// NOTE: A test file is either a file with a `.sol` extension in the `tests.solidity`
// directory or a file with a `.t.sol` extension in the `sources.solidity` directory
const rootFilePaths = (
await Promise.all([
getAllFilesMatching(hre.config.paths.tests.solidity, (f) =>
f.endsWith(".sol"),
),
...hre.config.paths.sources.solidity.map(async (dir) => {
return getAllFilesMatching(dir, (f) => f.endsWith(".t.sol"));
}),
)
).filter((artifact) => artifact !== undefined);
])
).flat(1);

if (testSuiteIds.length === 0) {
return;
}
const buildOptions: BuildOptions = {
force: false,
buildProfile: hre.globalOptions.buildProfile,
mergeCompilationJobs: shouldMergeCompilationJobs(
hre.globalOptions.buildProfile,
),
quiet: true,
};

const results = await hre.solidity.build(rootFilePaths, buildOptions);

throwIfSolidityBuildFailed(results);

const artifacts = await getArtifacts(results, hre.config.paths.artifacts);
const testSuiteIds = artifacts.map((artifact) => artifact.id);

console.log("Running Solidity tests");
console.log();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import "../../../types/config.js";

declare module "../../../types/config.js" {
export interface TestPathsUserConfig {
solidity?: string;
}

export interface TestPathsConfig {
solidity: string;
}

export interface SolidityTestUserConfig {
timeout?: number;
fsPermissions?: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type {
Artifact as HardhatArtifact,
BuildInfo,
} from "../../../types/artifacts.js";
import type { Artifact as EdrArtifact } from "@ignored/edr";

import path from "node:path";

import { HardhatError } from "@ignored/hardhat-vnext-errors";
import { readJsonFile } from "@ignored/hardhat-vnext-utils/fs";

import {
FileBuildResultType,
type CompilationJobCreationError,
type FailedFileBuildResult,
type FileBuildResult,
} from "../../../types/solidity.js";

type SolidityBuildResults =
| Map<string, FileBuildResult>
| CompilationJobCreationError;
type SuccessfulSolidityBuildResults = Map<
string,
Exclude<FileBuildResult, FailedFileBuildResult>
>;

/**
* This function asserts that the given Solidity build results are successful.
* It throws a HardhatError if the build results indicate that the compilation
* job failed.
*/
export function throwIfSolidityBuildFailed(
results: SolidityBuildResults,
): asserts results is SuccessfulSolidityBuildResults {
if ("reason" in results) {
throw new HardhatError(
HardhatError.ERRORS.SOLIDITY.COMPILATION_JOB_CREATION_ERROR,
{
reason: results.formattedReason,
rootFilePath: results.rootFilePath,
buildProfile: results.buildProfile,
},
);
}

const sucessful = [...results.values()].every(
({ type }) =>
type === FileBuildResultType.CACHE_HIT ||
type === FileBuildResultType.BUILD_SUCCESS,
);

if (!sucessful) {
throw new HardhatError(HardhatError.ERRORS.SOLIDITY.BUILD_FAILED);
}
}

/**
* This function returns the artifacts generated during the compilation associated
* with the given Solidity build results. It relies on the fact that each successful
* build result has a corresponding artifact generated property.
*/
export async function getArtifacts(
results: SuccessfulSolidityBuildResults,
artifactsRootPath: string,
): Promise<EdrArtifact[]> {
const artifacts: EdrArtifact[] = [];

for (const [source, result] of results.entries()) {
for (const artifactPath of result.contractArtifactsGenerated) {
const artifact: HardhatArtifact = await readJsonFile(artifactPath);
const buildInfo: BuildInfo = await readJsonFile(
path.join(artifactsRootPath, "build-info", `${result.buildId}.json`),
);

const id = {
name: artifact.contractName,
solcVersion: buildInfo.solcVersion,
source,
};

const contract = {
abi: JSON.stringify(artifact.abi),
bytecode: artifact.bytecode,
deployedBytecode: artifact.deployedBytecode,
};

artifacts.push({
id,
contract,
});
}
}

return artifacts;
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export interface SolidityBuildSystemOptions {

export class SolidityBuildSystemImplementation implements SolidityBuildSystem {
readonly #options: SolidityBuildSystemOptions;
readonly #defaultConcurrenty = Math.max(os.cpus().length - 1, 1);
readonly #defaultConcurrency = Math.max(os.cpus().length - 1, 1);
#downloadedCompilers = false;

constructor(options: SolidityBuildSystemOptions) {
Expand All @@ -78,7 +78,10 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem {
const localFilesToCompile = (
await Promise.all(
this.#options.soliditySourcesPaths.map((dir) =>
getAllFilesMatching(dir, (f) => f.endsWith(".sol")),
getAllFilesMatching(
dir,
(f) => f.endsWith(".sol") && !f.endsWith(".t.sol"),
),
),
)
).flat(1);
Expand Down Expand Up @@ -118,7 +121,7 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem {
compilationJobs,
(compilationJob) => this.runCompilationJob(compilationJob),
{
concurrency: options?.concurrency ?? this.#defaultConcurrenty,
concurrency: options?.concurrency ?? this.#defaultConcurrency,
// An error when running the compiler is not a compilation failure, but
// a fatal failure trying to run it, so we just throw on the first error
stopOnError: true,
Expand Down
Loading

0 comments on commit 34e9feb

Please sign in to comment.