UNPKG

hardhat

Version:

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

354 lines 16 kB
import path from "node:path"; import { styleText } from "node:util"; import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { ensureError } from "@nomicfoundation/hardhat-utils/error"; import { FileNotFoundError, readdir, readdirOrEmpty, readJsonFile, remove, writeJsonFile, } from "@nomicfoundation/hardhat-utils/fs"; import { isObject } from "@nomicfoundation/hardhat-utils/lang"; import { sanitizeFilename } from "@nomicfoundation/hardhat-utils/path"; import { getFullyQualifiedName, parseFullyQualifiedName, } from "../../../utils/contract-names.js"; import { formatSectionHeader, getUserFqn, isWithinTolerance, } from "./helpers/utils.js"; export const SNAPSHOT_CHEATCODES_DIR = "snapshots"; // Snapshot cheatcode values are uint256 decimal strings, so a stored value // that doesn't match this can only come from a hand-edited or corrupted file. const SNAPSHOT_VALUE_REGEX = /^\d+$/; export function getSnapshotCheatcodesPath(basePath, filename) { return path.join(basePath, SNAPSHOT_CHEATCODES_DIR, filename); } /** * Rekeys {@link snapshotCheatcodes} so each group name is safe to use as a * filename component, returning the rekeyed map alongside the list of names * that were actually changed by sanitization. * * @throws `SOLIDITY_TESTS.SNAPSHOT_GROUP_NAME_COLLISION` if two distinct * original names sanitize to the same on-disk filename. Originals are * sorted by codepoint in the error message so the same input always * produces the same error text. */ export function sanitizeSnapshotCheatcodes(snapshotCheatcodes) { const sanitizedSnapshotCheatcodes = new Map(); const originalBySanitized = new Map(); const renamedGroups = []; for (const [original, entries] of snapshotCheatcodes) { const sanitizedName = sanitizeFilename(original); const previousOriginal = originalBySanitized.get(sanitizedName); if (previousOriginal !== undefined) { const [nameA, nameB] = previousOriginal < original ? [previousOriginal, original] : [original, previousOriginal]; throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SNAPSHOT_GROUP_NAME_COLLISION, { nameA, nameB, sanitized: sanitizedName }); } originalBySanitized.set(sanitizedName, original); sanitizedSnapshotCheatcodes.set(sanitizedName, entries); if (sanitizedName !== original) { renamedGroups.push({ original, sanitized: sanitizedName }); } } return { snapshotCheatcodes: sanitizedSnapshotCheatcodes, renamedGroups, }; } export function extractSnapshotCheatcodes(suiteResults) { const snapshots = new Map(); for (const { id: suiteId, testResults } of suiteResults) { for (const { valueSnapshotGroups: snapshotGroups } of testResults) { if (snapshotGroups === undefined) { continue; } const userFqn = getUserFqn(getFullyQualifiedName(suiteId.source, suiteId.name)); for (const group of snapshotGroups) { let snapshot = snapshots.get(group.name); if (snapshot === undefined) { snapshot = {}; snapshots.set(group.name, snapshot); } for (const entry of group.entries) { snapshot[entry.name] = { value: entry.value, metadata: { source: parseFullyQualifiedName(userFqn).sourceName, }, }; } } } } return snapshots; } async function deleteOrphanedSnapshotFiles(snapshotsDir, currentGroups) { try { const dirEntries = await readdirOrEmpty(snapshotsDir); for (const entry of dirEntries) { if (entry.endsWith(".json")) { const groupName = entry.slice(0, -5); // remove .json if (!currentGroups.has(groupName)) { const filePath = path.join(snapshotsDir, entry); await remove(filePath); } } } } catch (error) { ensureError(error); throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SNAPSHOT_WRITE_ERROR, { snapshotsPath: snapshotsDir, error: error.message }, error); } } export async function writeSnapshotCheatcodes(basePath, snapshotCheatcodes) { const snapshotsDir = path.join(basePath, SNAPSHOT_CHEATCODES_DIR); // Delete old files that are no longer in the map const currentGroups = new Set(snapshotCheatcodes.keys()); await deleteOrphanedSnapshotFiles(snapshotsDir, currentGroups); // Write current snapshot files for (const [snapshotGroup, snapshot] of snapshotCheatcodes) { const snapshotCheatcodesPath = getSnapshotCheatcodesPath(basePath, `${snapshotGroup}.json`); const snapshotWithoutMetadata = {}; for (const [name, entry] of Object.entries(snapshot)) { snapshotWithoutMetadata[name] = entry.value; } try { await writeJsonFile(snapshotCheatcodesPath, snapshotWithoutMetadata); } catch (error) { ensureError(error); throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SNAPSHOT_WRITE_ERROR, { snapshotsPath: snapshotCheatcodesPath, error: error.message }, error); } } } export async function readSnapshotCheatcodes(basePath) { const snapshots = new Map(); const snapshotsDir = path.join(basePath, SNAPSHOT_CHEATCODES_DIR); let dirEntries; try { dirEntries = await readdir(snapshotsDir); } catch (error) { ensureError(error); // Re-throw as-is to allow the caller to handle this case specifically if (error instanceof FileNotFoundError) { throw error; } throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SNAPSHOT_READ_ERROR, { snapshotsPath: snapshotsDir, error: error.message }, error); } for (const entry of dirEntries) { if (entry.endsWith(".json")) { const snapshotGroup = entry.slice(0, -5); // remove .json extension const snapshotCheatcodesPath = getSnapshotCheatcodesPath(basePath, entry); let parsedSnapshot; try { parsedSnapshot = await readJsonFile(snapshotCheatcodesPath); } catch (error) { ensureError(error); throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SNAPSHOT_READ_ERROR, { snapshotsPath: snapshotCheatcodesPath, error: error.message }, error); } if (!isObject(parsedSnapshot)) { throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SNAPSHOT_READ_ERROR, { snapshotsPath: snapshotCheatcodesPath, error: `Invalid snapshot file: expected a JSON object, got ${JSON.stringify(parsedSnapshot)}`, }); } // Snapshot cheatcode values are always machine-generated uint256 // decimal strings (vm.snapshotValue/vm.snapshotGas*), so anything else // can only come from a hand-edited or corrupted file. This also rejects // hand-written unquoted JSON numbers (`100` instead of `"100"`). const snapshot = {}; for (const [name, value] of Object.entries(parsedSnapshot)) { if (typeof value !== "string" || !SNAPSHOT_VALUE_REGEX.test(value) || BigInt(value) >= 2n ** 256n) { throw new HardhatError(HardhatError.ERRORS.CORE.SOLIDITY_TESTS.SNAPSHOT_READ_ERROR, { snapshotsPath: snapshotCheatcodesPath, error: `Invalid value ${JSON.stringify(value)} for "${name}". Snapshot values must be uint256 decimal integer strings`, }); } snapshot[name] = value; } snapshots.set(snapshotGroup, snapshot); } } return snapshots; } export function stringifySnapshotCheatcodes(snapshots) { const lines = []; for (const { group, name, value } of snapshots) { lines.push(`${group}#${name}: ${value}`); } return lines.sort((a, b) => a.localeCompare(b)).join("\n"); } export function compareSnapshotCheatcodes(previousSnapshotsMap, currentSnapshotsMap, tolerance) { const added = []; const removed = []; const changed = []; const tolerated = []; const seenPreviousEntries = new Set(); for (const [group, currentSnapshots] of currentSnapshotsMap) { const previousSnapshots = previousSnapshotsMap.get(group); for (const [name, currentEntry] of Object.entries(currentSnapshots)) { const key = `${group}#${name}`; if (previousSnapshots === undefined || !Object.hasOwn(previousSnapshots, name)) { added.push({ group, name, value: currentEntry.value }); } else { seenPreviousEntries.add(key); const previousValue = previousSnapshots[name]; if (previousValue !== currentEntry.value) { const change = { group, name, expected: previousValue, actual: currentEntry.value, source: currentEntry.metadata.source, }; // Values above 2^53 lose precision when coerced to `number`, but // the resulting relative error (~1e-14%) is far below any usable // tolerance, so it can't change the outcome of this check. if (tolerance > 0 && isWithinTolerance(Number(previousValue), Number(currentEntry.value), tolerance)) { tolerated.push(change); } else { changed.push(change); } } } } } for (const [group, previousSnapshots] of previousSnapshotsMap) { for (const [name, previousValue] of Object.entries(previousSnapshots)) { const key = `${group}#${name}`; if (!seenPreviousEntries.has(key)) { removed.push({ group, name, value: previousValue }); } } } const sortByKey = (a, b) => `${a.group}#${a.name}`.localeCompare(`${b.group}#${b.name}`); // Sort the results for consistent output return { added: added.sort(sortByKey), removed: removed.sort(sortByKey), changed: changed.sort(sortByKey), tolerated: tolerated.sort(sortByKey), }; } export async function checkSnapshotCheatcodes(basePath, suiteResults, tolerance) { const { snapshotCheatcodes, renamedGroups } = sanitizeSnapshotCheatcodes(extractSnapshotCheatcodes(suiteResults)); let previousSnapshotCheatcodes; try { previousSnapshotCheatcodes = await readSnapshotCheatcodes(basePath); } catch (error) { if (error instanceof FileNotFoundError) { // Running a check without stored snapshots is a mistake: fail so it's // caught, but only when this run actually produced something to check. const noBaseline = snapshotCheatcodes.size > 0; return { passed: !noBaseline, comparison: { added: [], removed: [], changed: [], tolerated: [], }, noBaseline, renamedGroups, }; } throw error; } const comparison = compareSnapshotCheatcodes(previousSnapshotCheatcodes, snapshotCheatcodes, tolerance); return { passed: comparison.changed.length === 0, comparison, noBaseline: false, renamedGroups, }; } export function logSnapshotCheatcodesSection(result, logger = console.log, isFiltered = false) { const { comparison, noBaseline } = result; const changedLength = comparison.changed.length; const hasChanges = changedLength > 0; // On a filtered run (--grep, --grep-exclude, or specific files), added and // missing snapshots are mostly artifacts of the filter rather than real // differences, so they aren't reported. const addedLength = isFiltered ? 0 : comparison.added.length; const removedLength = isFiltered ? 0 : comparison.removed.length; const hasAdded = addedLength > 0; const hasRemoved = removedLength > 0; const hasAnyDifferences = hasChanges || hasAdded || hasRemoved; // Nothing to report if (!noBaseline && !hasAnyDifferences) { return; } if (noBaseline) { logger(styleText("yellow", "Snapshot cheatcodes: no snapshots found. Run your tests with --snapshot to create one.")); logger(); return; } logger(formatSectionHeader("Snapshot cheatcodes", { changedLength, addedLength, removedLength, })); if (hasChanges) { logger(); printSnapshotCheatcodeChanges(comparison.changed, logger); } if (hasAdded) { logger(); logger(` ${comparison.added.length} snapshot(s) produced by this run are not in the snapshot:`); const addedLines = stringifySnapshotCheatcodes(comparison.added).split("\n"); for (const line of addedLines) { logger(styleText("green", ` + ${line}`)); } } if (hasRemoved) { logger(); logger(` ${comparison.removed.length} stored snapshot(s) were not produced by this run:`); const removedLines = stringifySnapshotCheatcodes(comparison.removed).split("\n"); for (const line of removedLines) { logger(styleText("red", ` - ${line}`)); } } logger(); } export function printSnapshotCheatcodeChanges(changes, logger = console.log) { for (let i = 0; i < changes.length; i++) { const change = changes[i]; const isLast = i === changes.length - 1; logger(` ${change.group}#${change.name}`); logger(styleText("grey", ` (in ${change.source})`)); logger(styleText("grey", ` Expected: ${change.expected}`)); // Snapshot values are uint256 decimal strings that can exceed 2^53, so // the exact diff needs BigInt. const diff = BigInt(change.actual) - BigInt(change.expected); const formattedDiff = diff > 0 ? `Δ+${diff}` : ${diff}`; let gasChange = `${formattedDiff}`; const expected = Number(change.expected); if (expected > 0) { const percent = (Number(diff) / expected) * 100; const formattedPercent = percent >= 0 ? `+${percent.toFixed(2)}%` : `${percent.toFixed(2)}%`; gasChange = `${formattedPercent}, ${formattedDiff}`; } // Color: green for decrease (improvement), red for increase (regression) const formattedGasChange = diff < 0 ? styleText("green", gasChange) : styleText("red", gasChange); logger(styleText("grey", ` Actual: ${change.actual} (`) + formattedGasChange + styleText("grey", ")")); if (!isLast) { logger(); } } } export function logSnapshotRenameWarnings(renamedGroups, logger = console.log) { if (renamedGroups.length === 0) { return; } logger(styleText("yellow", `Renamed ${renamedGroups.length} snapshot group name(s) for safe filesystem use:`)); for (const { original, sanitized } of renamedGroups) { logger(styleText("yellow", ` "${original}" → "${sanitized}"`)); } logger(styleText("yellow", "If you'd like the on-disk filename(s) to match exactly, consider renaming the group(s) in Solidity.")); logger(); } //# sourceMappingURL=snapshot-cheatcodes.js.map