Skip to main content

forge/cmd/test/
mod.rs

1use super::{install, watch::WatchArgs};
2use crate::{
3    MultiContractRunner, MultiContractRunnerBuilder,
4    decode::decode_console_logs,
5    diagnostic::build::SOLC_ERROR,
6    gas_report::GasReport,
7    multi_runner::{MultiNetworkConfig, ShowmapConfig, matches_artifact},
8    mutation::{MutationRunConfig, run_mutation_testing},
9    result::{SuiteResult, TestKindReport, TestOutcome, TestResult, TestStatus},
10    traces::{
11        CallTraceDecoderBuilder, InternalTraceMode, TraceKind,
12        debug::{ContractSources, DebugTraceIdentifier},
13        decode_trace_arena, folded_stack_trace,
14        identifier::SignaturesIdentifier,
15    },
16};
17use alloy_primitives::U256;
18use chrono::Utc;
19use clap::{Parser, ValueEnum, ValueHint};
20use eyre::{Context, OptionExt, Result, bail};
21use foundry_cli::{
22    ExitCode,
23    json::{JsonEnvelope, JsonMessage, print_json},
24    opts::{BuildOpts, EvmArgs, GlobalArgs},
25    utils::{self, LoadConfig},
26};
27use foundry_common::{
28    EmptyTestFilter, TestFilter, TestFunctionExt, compile::ProjectCompiler, fs, shell,
29};
30use foundry_compilers::{
31    CompilationError, ProjectCompileOutput,
32    artifacts::{Libraries, output_selection::OutputSelection},
33    compilers::{
34        Language,
35        multi::{MultiCompiler, MultiCompilerLanguage},
36    },
37    utils::source_files_iter,
38};
39use foundry_config::{
40    Config, InlineConfig, InvariantWorkers, figment,
41    figment::{
42        Metadata, Profile, Provider,
43        value::{Dict, Map, Value},
44    },
45    filter::GlobMatcher,
46};
47use foundry_debugger::Debugger;
48#[cfg(feature = "optimism")]
49use foundry_evm::core::evm::OpEvmNetwork;
50use foundry_evm::{
51    core::evm::{
52        BlockEnvFor, EthEvmNetwork, FoundryEvmNetwork, SpecFor, TempoEvmNetwork, TxEnvFor,
53    },
54    executors::ShowmapDomain,
55    fuzz::CounterExample,
56    hardforks::TempoHardfork,
57    opts::EvmOpts,
58    traces::{backtrace::BacktraceBuilder, identifier::TraceIdentifiers, prune_trace_depth},
59};
60use rand::Rng;
61use regex::Regex;
62use revm::context::Transaction;
63use std::{
64    collections::{BTreeMap, BTreeSet},
65    fmt::Write,
66    path::{Path, PathBuf},
67    sync::{Arc, mpsc::channel},
68    time::{Duration, Instant},
69};
70use yansi::Paint;
71
72mod filter;
73mod summary;
74use crate::{result::TestKind, traces::render_trace_arena_inner};
75pub use filter::{FilterArgs, ProjectPathsAwareFilter};
76use filter::{RerunFailure, RerunFailures};
77use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite};
78use summary::{TestSummaryReport, format_invariant_metrics_table};
79
80// Loads project's figment and merges the build cli arguments into it
81foundry_config::merge_impl_figment_convert!(TestArgs, build, evm);
82
83/// CLI mirror of `foundry_evm::executors::ShowmapDomain`.
84#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
85#[clap(rename_all = "lowercase")]
86pub enum ShowmapDomainArg {
87    #[default]
88    Evm,
89    Sancov,
90    Both,
91}
92
93impl From<ShowmapDomainArg> for ShowmapDomain {
94    fn from(d: ShowmapDomainArg) -> Self {
95        match d {
96            ShowmapDomainArg::Evm => Self::Evm,
97            ShowmapDomainArg::Sancov => Self::Sancov,
98            ShowmapDomainArg::Both => Self::Both,
99        }
100    }
101}
102
103/// CLI arguments for `forge test`.
104#[derive(Clone, Debug, Parser)]
105#[command(next_help_heading = "Test options")]
106pub struct TestArgs {
107    // Include global options for users of this struct.
108    #[command(flatten)]
109    pub global: GlobalArgs,
110
111    /// The contract file you want to test, it's a shortcut for --match-path.
112    #[arg(value_hint = ValueHint::FilePath)]
113    pub path: Option<GlobMatcher>,
114
115    /// Run a single test in the debugger.
116    ///
117    /// The matching test will be opened in the debugger regardless of the outcome of the test.
118    ///
119    /// If the matching test is a fuzz test, then it will open the debugger on the first failure
120    /// case. If the fuzz test does not fail, it will open the debugger on the last fuzz case.
121    #[arg(long, conflicts_with_all = ["flamegraph", "flamechart", "decode_internal", "rerun"])]
122    debug: bool,
123
124    /// Generate a flamegraph for a single test. Implies `--decode-internal`.
125    ///
126    /// A flame graph is used to visualize which functions or operations within the smart contract
127    /// are consuming the most gas overall in a sorted manner.
128    #[arg(long)]
129    flamegraph: bool,
130
131    /// Generate a flamechart for a single test. Implies `--decode-internal`.
132    ///
133    /// A flame chart shows the gas usage over time, illustrating when each function is
134    /// called (execution order) and how much gas it consumes at each point in the timeline.
135    #[arg(long, conflicts_with = "flamegraph")]
136    flamechart: bool,
137
138    /// Identify internal functions in traces.
139    ///
140    /// This will trace internal functions and decode stack parameters.
141    ///
142    /// Parameters stored in memory (such as bytes or arrays) are currently decoded only when a
143    /// single function is matched, similarly to `--debug`, for performance reasons.
144    #[arg(long)]
145    decode_internal: bool,
146
147    /// Dumps all debugger steps to file.
148    #[arg(
149        long,
150        requires = "debug",
151        value_hint = ValueHint::FilePath,
152        value_name = "PATH"
153    )]
154    dump: Option<PathBuf>,
155
156    /// Print a gas report.
157    #[arg(long, env = "FORGE_GAS_REPORT")]
158    gas_report: bool,
159
160    /// Check gas snapshots against previous runs.
161    #[arg(long, env = "FORGE_SNAPSHOT_CHECK")]
162    gas_snapshot_check: Option<bool>,
163
164    /// Enable/disable recording of gas snapshot results.
165    #[arg(long, env = "FORGE_SNAPSHOT_EMIT")]
166    gas_snapshot_emit: Option<bool>,
167
168    /// Exit with code 0 even if a test fails.
169    #[arg(long, env = "FORGE_ALLOW_FAILURE")]
170    allow_failure: bool,
171
172    /// Suppress successful test traces and show only traces for failures.
173    #[arg(long, short, env = "FORGE_SUPPRESS_SUCCESSFUL_TRACES", help_heading = "Display options")]
174    suppress_successful_traces: bool,
175
176    /// Defines the depth of a trace
177    #[arg(long)]
178    trace_depth: Option<usize>,
179
180    /// Output test results as JUnit XML report.
181    #[arg(long, conflicts_with_all = ["quiet", "json", "gas_report", "summary", "list", "show_progress"], help_heading = "Display options")]
182    pub junit: bool,
183
184    /// Stop running tests after the first failure.
185    #[arg(long)]
186    pub fail_fast: bool,
187
188    /// The Etherscan (or equivalent) API key.
189    #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")]
190    etherscan_api_key: Option<String>,
191
192    /// List tests instead of running them.
193    #[arg(long, short, conflicts_with_all = ["show_progress", "decode_internal", "summary"], help_heading = "Display options")]
194    list: bool,
195
196    /// Set seed used to generate randomness during your fuzz runs.
197    #[arg(long)]
198    pub fuzz_seed: Option<U256>,
199
200    #[arg(long, env = "FOUNDRY_FUZZ_RUNS", value_name = "RUNS")]
201    pub fuzz_runs: Option<u64>,
202
203    /// Number of workers to use for invariant test campaigns, or `auto` to derive from `--jobs`.
204    #[arg(long, env = "FOUNDRY_INVARIANT_WORKERS", value_name = "WORKERS")]
205    pub invariant_workers: Option<InvariantWorkers>,
206
207    /// Run only the fuzz case at the given 1-based run index.
208    #[arg(long, env = "FOUNDRY_FUZZ_RUN", value_name = "RUN")]
209    pub fuzz_run: Option<u32>,
210
211    /// Run the fuzz case from the given worker. Requires `--fuzz-run`.
212    #[arg(long, env = "FOUNDRY_FUZZ_WORKER", value_name = "WORKER", requires = "fuzz_run")]
213    pub fuzz_worker: Option<u32>,
214
215    /// Timeout for each fuzz run in seconds.
216    #[arg(long, env = "FOUNDRY_FUZZ_TIMEOUT", value_name = "TIMEOUT")]
217    pub fuzz_timeout: Option<u64>,
218
219    /// File to rerun fuzz failures from.
220    #[arg(long)]
221    pub fuzz_input_file: Option<String>,
222
223    /// Run symbolic check*/prove*/invariant*/statefulFuzz* tests.
224    #[arg(long, env = "FOUNDRY_SYMBOLIC")]
225    pub symbolic: bool,
226
227    /// Solver executable used for symbolic tests.
228    #[arg(long, env = "FOUNDRY_SYMBOLIC_SOLVER", value_name = "PATH_OR_NAME")]
229    pub symbolic_solver: Option<String>,
230
231    /// Exact solver command used for symbolic tests.
232    #[arg(long, env = "FOUNDRY_SYMBOLIC_SOLVER_COMMAND", value_name = "COMMAND")]
233    pub symbolic_solver_command: Option<String>,
234
235    /// Comma-separated SMT solver names or commands to race in parallel for symbolic tests.
236    #[arg(
237        long,
238        env = "FOUNDRY_SYMBOLIC_SOLVER_PORTFOLIO",
239        value_delimiter = ',',
240        value_name = "SOLVER_OR_COMMAND,..."
241    )]
242    pub symbolic_solver_portfolio: Option<Vec<String>>,
243
244    /// Timeout for symbolic execution in seconds.
245    #[arg(long, env = "FOUNDRY_SYMBOLIC_TIMEOUT", value_name = "SECONDS")]
246    pub symbolic_timeout: Option<u32>,
247
248    /// Halmos-compatible symbolic loop bound.
249    #[arg(long, env = "FOUNDRY_SYMBOLIC_LOOP", value_name = "N")]
250    pub symbolic_loop: Option<u32>,
251
252    /// Halmos-compatible symbolic execution depth alias.
253    #[arg(long, env = "FOUNDRY_SYMBOLIC_DEPTH", value_name = "N")]
254    pub symbolic_depth: Option<u32>,
255
256    /// Halmos-compatible symbolic path width alias.
257    #[arg(long, env = "FOUNDRY_SYMBOLIC_WIDTH", value_name = "N")]
258    pub symbolic_width: Option<u32>,
259
260    /// Maximum number of opcodes executed along a symbolic path.
261    #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_DEPTH", value_name = "N")]
262    pub symbolic_max_depth: Option<u32>,
263
264    /// Maximum number of symbolic paths to explore per test.
265    #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_PATHS", value_name = "N")]
266    pub symbolic_max_paths: Option<u32>,
267
268    /// Maximum number of calls in a bounded symbolic invariant sequence.
269    #[arg(long, env = "FOUNDRY_SYMBOLIC_INVARIANT_DEPTH", value_name = "N")]
270    pub symbolic_invariant_depth: Option<u32>,
271
272    /// Maximum number of solver queries per symbolic test.
273    #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_SOLVER_QUERIES", value_name = "N")]
274    pub symbolic_max_solver_queries: Option<u32>,
275
276    /// Default bounded length for symbolic dynamic ABI inputs.
277    #[arg(long, env = "FOUNDRY_SYMBOLIC_DEFAULT_DYNAMIC_LENGTH", value_name = "N")]
278    pub symbolic_default_dynamic_length: Option<u32>,
279
280    /// Maximum permitted bounded length for symbolic dynamic ABI inputs.
281    #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_DYNAMIC_LENGTH", value_name = "N")]
282    pub symbolic_max_dynamic_length: Option<u32>,
283
284    /// Per-dynamic-input symbolic lengths, applied in ABI traversal order.
285    #[arg(
286        long,
287        env = "FOUNDRY_SYMBOLIC_ARRAY_LENGTHS",
288        value_delimiter = ',',
289        value_name = "N,..."
290    )]
291    pub symbolic_array_lengths: Option<Vec<u32>>,
292
293    /// Maximum symbolic calldata size in bytes.
294    #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_CALLDATA_BYTES", value_name = "N")]
295    pub symbolic_max_calldata_bytes: Option<u32>,
296
297    /// Expand symbolic external call targets over known deployed contracts.
298    #[arg(long, env = "FOUNDRY_SYMBOLIC_CALL_TARGETS")]
299    pub symbolic_call_targets: bool,
300
301    /// Dump SMT-LIB queries issued by symbolic tests.
302    #[arg(long, env = "FOUNDRY_SYMBOLIC_DUMP_SMT")]
303    pub symbolic_dump_smt: bool,
304
305    /// Symbolic storage modelling mode.
306    #[arg(
307        long,
308        env = "FOUNDRY_SYMBOLIC_STORAGE_LAYOUT",
309        value_name = "solidity|generic",
310        value_parser = ["solidity", "generic"]
311    )]
312    pub symbolic_storage_layout: Option<String>,
313
314    /// Show test execution progress.
315    #[arg(long, conflicts_with_all = ["quiet", "json"], help_heading = "Display options")]
316    pub show_progress: bool,
317
318    /// Re-run recorded test failures from last run.
319    /// If no failure recorded then regular test run is performed.
320    #[arg(long)]
321    pub rerun: bool,
322
323    /// Print test summary table.
324    #[arg(long, help_heading = "Display options")]
325    pub summary: bool,
326
327    /// Print detailed test summary table.
328    #[arg(long, help_heading = "Display options", requires = "summary")]
329    pub detailed: bool,
330
331    /// Disables the labels in the traces.
332    #[arg(long, help_heading = "Display options")]
333    pub disable_labels: bool,
334
335    /// Replay the persisted corpus and emit AFL-`afl-showmap`-style coverage
336    /// files at the given output directory. Disables the regular fuzz/invariant
337    /// campaign and skips unit tests.
338    #[arg(
339        long,
340        value_name = "DIR",
341        value_hint = ValueHint::DirPath,
342        help_heading = "Showmap replay",
343        conflicts_with_all = ["debug", "flamegraph", "flamechart", "rerun", "fuzz_input_file", "gas_report"],
344    )]
345    pub showmap_out: Option<PathBuf>,
346
347    /// Emit one showmap file per corpus entry (default: one aggregated file per test).
348    #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")]
349    pub showmap_per_input: bool,
350
351    /// Coverage domain(s) to dump.
352    #[arg(
353        long,
354        value_enum,
355        default_value_t = ShowmapDomainArg::Evm,
356        help_heading = "Showmap replay",
357        requires = "showmap_out",
358    )]
359    pub showmap_domain: ShowmapDomainArg,
360
361    /// Approach name (used as a subdirectory of `--showmap-out`).
362    #[arg(
363        long,
364        default_value = "replay",
365        help_heading = "Showmap replay",
366        requires = "showmap_out"
367    )]
368    pub showmap_approach: String,
369
370    /// Trial identifier embedded in each showmap filename. Defaults to a unique
371    /// `trial-<unix_nanos>` so reruns don't overwrite previous trials.
372    #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")]
373    pub showmap_trial: Option<String>,
374
375    /// Override the corpus directory to replay (defaults to the per-test
376    /// `corpus_dir` resolved from config).
377    #[arg(
378        long,
379        value_name = "PATH",
380        value_hint = ValueHint::DirPath,
381        help_heading = "Showmap replay",
382        requires = "showmap_out",
383    )]
384    pub showmap_corpus_dir: Option<PathBuf>,
385
386    #[command(flatten)]
387    filter: FilterArgs,
388
389    #[command(flatten)]
390    evm: EvmArgs,
391
392    #[command(flatten)]
393    pub build: BuildOpts,
394
395    #[command(flatten)]
396    pub watch: WatchArgs,
397
398    /// Enable mutation testing.
399    /// If passed with file paths, only those files will be tested.
400    #[arg(long, num_args(0..), value_name = "PATH")]
401    pub mutate: Option<Vec<PathBuf>>,
402
403    /// Specify which files to mutate with glob pattern matching.
404    ///
405    /// Mutually exclusive with passing explicit paths to `--mutate`; either
406    /// supply paths to `--mutate` or use this glob filter, not both.
407    #[arg(long, value_name = "PATTERN", requires = "mutate", conflicts_with = "mutate_contract")]
408    pub mutate_path: Option<GlobMatcher>,
409
410    /// Only mutate contracts whose name matches the specified regex pattern.
411    ///
412    /// Mutually exclusive with `--mutate-path`.
413    #[arg(long, value_name = "REGEX", requires = "mutate")]
414    pub mutate_contract: Option<regex::Regex>,
415
416    /// Number of parallel workers for mutation testing.
417    /// Defaults to the number of CPU cores.
418    #[arg(long, value_name = "JOBS", requires = "mutate")]
419    pub mutation_jobs: Option<usize>,
420
421    /// Best-effort per-mutant wall-clock timeout in seconds. Mutants that
422    /// exceed it are recorded as "timed out" and cleanup continues in the
423    /// background with bounded pending workers.
424    ///
425    /// Analogous to `--invariant-timeout` for invariant campaigns.
426    #[arg(long, value_name = "TIMEOUT", requires = "mutate")]
427    pub mutation_timeout: Option<u32>,
428}
429
430impl TestArgs {
431    pub async fn run(mut self) -> Result<TestOutcome> {
432        trace!(target: "forge::test", "executing test command");
433        self.compile_and_run().await
434    }
435
436    /// Builds a `ShowmapConfig` from the showmap CLI flags, if `--showmap-out` is set.
437    fn showmap_config(&self) -> Option<ShowmapConfig> {
438        // Default trial id uses nanosecond precision so back-to-back invocations
439        // don't collide and overwrite each other's output files.
440        let trial = self.showmap_trial.clone().unwrap_or_else(|| {
441            let ns = std::time::SystemTime::now()
442                .duration_since(std::time::UNIX_EPOCH)
443                .map(|d| d.as_nanos())
444                .unwrap_or(0);
445            format!("trial-{ns}")
446        });
447        Some(ShowmapConfig {
448            out_dir: self.showmap_out.clone()?,
449            approach: self.showmap_approach.clone(),
450            trial,
451            per_input: self.showmap_per_input,
452            domain: self.showmap_domain.into(),
453            corpus_dir: self.showmap_corpus_dir.clone(),
454        })
455    }
456
457    /// Reject flags whose stdout shape conflicts with the NDJSON stream
458    /// contract under `--machine`. Called from the binary entry point so
459    /// `--watch` is also rejected.
460    pub(crate) fn reject_machine_unsupported_flags(&self) -> Result<()> {
461        if !foundry_cli::is_machine() {
462            return Ok(());
463        }
464        let unsupported = [
465            ("--watch", self.is_watch()),
466            ("--debug", self.debug),
467            ("--flamegraph", self.flamegraph),
468            ("--flamechart", self.flamechart),
469            ("--gas-report", self.gas_report),
470            ("--summary", self.summary),
471            ("--list", self.list),
472            ("--junit", self.junit),
473            ("--show-progress", self.show_progress),
474            ("--mutate", self.mutate.is_some()),
475            // `--live-logs` writes console.log straight to stdout; the
476            // `live_logs = true` config equivalent is overridden in
477            // `compile_and_run`.
478            ("--live-logs", self.evm.live_logs),
479            // Bails mid-suite on diff; config equivalent overridden in `compile_and_run`.
480            ("--gas-snapshot-check", self.gas_snapshot_check.unwrap_or(false)),
481            // Writes mid-suite to disk and can fail between test_result and
482            // suite_finished; config equivalent overridden in `compile_and_run`.
483            ("--gas-snapshot-emit", self.gas_snapshot_emit == Some(true)),
484        ]
485        .into_iter()
486        .filter_map(|(name, on)| on.then_some(name))
487        .collect::<Vec<_>>();
488        if !unsupported.is_empty() {
489            foundry_cli::machine::bail_machine_usage_with_details(
490                format!(
491                    "`forge test` under `--machine` does not yet support {}; \
492                     run without `--machine` or omit those flags.",
493                    unsupported.join(", ")
494                ),
495                serde_json::json!({ "unsupported_flags": unsupported }),
496            );
497        }
498        Ok(())
499    }
500
501    /// Returns a list of files that need to be compiled in order to run all the tests that match
502    /// the given filter.
503    ///
504    /// This means that it will return all sources that are not test contracts or that match the
505    /// filter. We want to compile all non-test sources always because tests might depend on them
506    /// dynamically through cheatcodes.
507    #[instrument(target = "forge::test", skip_all)]
508    pub fn get_sources_to_compile(
509        &self,
510        config: &Config,
511        test_filter: &ProjectPathsAwareFilter,
512    ) -> Result<BTreeSet<PathBuf>> {
513        // An empty filter doesn't filter out anything.
514        // We can still optimize slightly by excluding scripts.
515        if test_filter.is_empty() {
516            return Ok(source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
517                .chain(source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS))
518                .collect());
519        }
520
521        let filter_args = test_filter.args();
522        let has_contract_or_test_filter = filter_args.test_pattern.is_some()
523            || filter_args.test_pattern_inverse.is_some()
524            || filter_args.contract_pattern.is_some()
525            || filter_args.contract_pattern_inverse.is_some();
526        if !has_contract_or_test_filter {
527            return Ok(source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
528                .chain(
529                    source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS)
530                        .filter(|path| test_filter.matches_path(path)),
531                )
532                .collect());
533        }
534
535        let mut project = config.create_project(true, true)?;
536        project.update_output_selection(|selection| {
537            *selection = OutputSelection::common_output_selection(["abi".to_string()]);
538        });
539        let output = project.compile()?;
540        if output.has_compiler_errors() {
541            // Mirror the main-compile typed envelope so agents don't see this
542            // path as `cli.unknown` + exit 1.
543            if foundry_cli::is_machine() {
544                emit_machine_compile_error(&output);
545            }
546            sh_println!("{output}")?;
547            eyre::bail!("Compilation failed");
548        }
549
550        // `MultiContractRunner::build` strips the root prefix from artifact source paths so the
551        // identifiers it constructs are project-relative. Match that here for the filter check
552        // (notably for the `--rerun` failure list, which is persisted relative) but return the
553        // original absolute source paths so downstream compilation can locate them.
554        Ok(output
555            .artifact_ids()
556            .filter_map(|(id, artifact)| artifact.abi.as_ref().map(|abi| (id, abi)))
557            .filter(|(id, abi)| {
558                if id.source.starts_with(&config.src) {
559                    return true;
560                }
561                let stripped = id.clone().with_stripped_file_prefixes(&config.root);
562                matches_artifact(test_filter, &stripped, abi, config.symbolic.enabled)
563            })
564            .map(|(id, _)| id.source)
565            .collect())
566    }
567
568    /// Executes all the tests in the project.
569    ///
570    /// This will trigger the build process first. On success all test contracts that match the
571    /// configured filter will be executed
572    ///
573    /// Returns the test results for all matching tests.
574    pub async fn compile_and_run(&mut self) -> Result<TestOutcome> {
575        let machine_mode = foundry_cli::is_machine();
576
577        // Merge all configs.
578        let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
579
580        let should_mutate = self.mutate.is_some();
581
582        // Force dyn test linking for mutation testing
583        if should_mutate {
584            config.dynamic_test_linking = true;
585            config.cache = true;
586        }
587
588        // Override foundry.toml knobs that would print outside the NDJSON
589        // stream or bail mid-suite; the CLI equivalents are rejected in
590        // `reject_machine_unsupported_flags`.
591        if machine_mode {
592            config.show_progress = false;
593            config.live_logs = false;
594            config.gas_snapshot_check = false;
595            config.gas_snapshot_emit = false;
596        }
597
598        // Skip implicit dep install: it prints to stdout. A missing dep then
599        // surfaces as a typed `compiler.solc.error` from the compile below.
600        if !machine_mode
601            && install::install_missing_dependencies(&mut config).await
602            && config.auto_detect_remappings
603        {
604            // need to re-configure here to also catch additional remappings
605            config = self.load_config()?;
606        }
607
608        // Set up the project.
609        let project = config.project()?;
610
611        let filter = self.filter(&config)?;
612        trace!(target: "forge::test", ?filter, "using filter");
613
614        let mut compiler = ProjectCompiler::new()
615            .dynamic_test_linking(config.dynamic_test_linking)
616            .quiet(shell::is_json() || self.junit || machine_mode)
617            .files(self.get_sources_to_compile(&config, &filter)?);
618        // Disable inner `bail` so a compile error returns the output and we
619        // can emit a typed envelope instead of an untyped `cli.unknown`.
620        if machine_mode {
621            compiler = compiler.bail(false);
622        }
623        let output = compiler.compile(&project)?;
624
625        if machine_mode && output.has_compiler_errors() {
626            emit_machine_compile_error(&output);
627        }
628
629        self.run_tests(&project.paths.root, config, evm_opts, &output, &filter, false).await
630    }
631
632    /// Executes all the tests in the project.
633    ///
634    /// See [`Self::compile_and_run`] for more details.
635    pub async fn run_tests(
636        &mut self,
637        project_root: &Path,
638        mut config: Config,
639        mut evm_opts: EvmOpts,
640        output: &ProjectCompileOutput,
641        filter: &ProjectPathsAwareFilter,
642        coverage: bool,
643    ) -> Result<TestOutcome> {
644        if config.fuzz.run == Some(0) {
645            bail!("`fuzz.run` must be greater than 0");
646        }
647
648        // Mutation testing has bespoke orchestration (per-mutant temp
649        // workspaces, baseline + N mutants, aggregated mutation report). It is
650        // not compatible with the single-run debug / flame / list / junit
651        // modes — running them together would either mix incompatible output
652        // formats, or run the secondary mode against the baseline tests and
653        // then silently continue into mutation testing. Reject up front with a
654        // clear error rather than do the wrong thing.
655        if self.mutate.is_some() {
656            let mut conflicts = Vec::new();
657            if self.list {
658                conflicts.push("--list");
659            }
660            if self.debug {
661                conflicts.push("--debug");
662            }
663            if self.flamegraph {
664                conflicts.push("--flamegraph");
665            }
666            if self.flamechart {
667                conflicts.push("--flamechart");
668            }
669            if self.junit {
670                conflicts.push("--junit");
671            }
672            if coverage {
673                conflicts.push("coverage");
674            }
675            if self.showmap_out.is_some() {
676                conflicts.push("--showmap-out");
677            }
678            if !conflicts.is_empty() {
679                bail!(
680                    "`--mutate` cannot be combined with: {}. Re-run without those flags to use \
681                     mutation testing.",
682                    conflicts.join(", ")
683                );
684            }
685        }
686
687        // Explicitly enable isolation for gas reports for more correct gas accounting.
688        if self.gas_report {
689            evm_opts.isolate = true;
690        } else {
691            // Do not collect gas report traces if gas report is not enabled.
692            config.fuzz.gas_report_samples = 0;
693            config.invariant.gas_report_samples = 0;
694        }
695
696        // Generate a random fuzz seed if none provided, for reproducibility.
697        config.fuzz.seed = config
698            .fuzz
699            .seed
700            .or_else(|| Some(U256::from_be_bytes(rand::rng().random::<[u8; 32]>())));
701
702        // Create test options from general project settings and compiler output.
703        let should_debug = self.debug;
704        let should_draw = self.flamegraph || self.flamechart;
705
706        // Determine executor verbosity.
707        if (self.gas_report && evm_opts.verbosity < 3) || self.flamegraph || self.flamechart {
708            evm_opts.verbosity = 3;
709        }
710
711        // Enable internal tracing for more informative flamegraph.
712        if should_draw && !self.decode_internal {
713            self.decode_internal = true;
714        }
715
716        // Choose the internal function tracing mode, if --decode-internal is provided.
717        let decode_internal = if self.decode_internal {
718            // If more than one function matched, we enable simple tracing.
719            // If only one function matched, we enable full tracing. This is done in `run_tests`.
720            InternalTraceMode::Simple
721        } else {
722            InternalTraceMode::None
723        };
724
725        // Auto-detect network from fork chain ID when not explicitly configured.
726        evm_opts.infer_network_from_fork().await;
727
728        // Clone config and evm_opts before dispatch (needed for mutation testing).
729        let config_for_mutation = config.clone();
730        let evm_opts_for_mutation = evm_opts.clone();
731
732        // Parse inline config early to detect per-test network annotations.
733        let inline_config = InlineConfig::new_parsed(output, &config)?;
734        let override_networks = inline_config.referenced_override_networks(&config.profile);
735
736        // Multi-pass would emit `test_result*` + `suite_finished` once per
737        // pass for the same suite, violating "exactly one terminator per group".
738        if foundry_cli::is_machine() && !override_networks.is_empty() {
739            let networks: Vec<String> = override_networks.iter().map(|n| n.to_string()).collect();
740            foundry_cli::machine::bail_machine_usage_with_details(
741                "`forge test` under `--machine` does not yet support inline network \
742                 overrides; run without `--machine` or remove the inline `network` \
743                 annotations.",
744                serde_json::json!({
745                    "unsupported_features": ["inline_network_overrides"],
746                    "networks": networks,
747                }),
748            );
749        }
750
751        let (libraries, mut outcome) = if override_networks.is_empty() {
752            // Single-pass: no per-test network overrides, use global network setting.
753            self.dispatch_network(
754                &evm_opts,
755                config,
756                evm_opts.clone(),
757                output,
758                filter,
759                coverage,
760                should_debug,
761                decode_internal,
762                MultiNetworkConfig::default(),
763            )
764            .await?
765        } else {
766            // Multi-pass: run each distinct network separately and merge results.
767            let all_override_networks = override_networks.clone();
768            let multi_pass_timer = Instant::now();
769
770            // Default pass: global network, runs tests without an explicit network annotation.
771            let (libraries, mut outcome) = self
772                .dispatch_network(
773                    &evm_opts,
774                    config.clone(),
775                    evm_opts.clone(),
776                    output,
777                    filter,
778                    coverage,
779                    should_debug,
780                    decode_internal,
781                    MultiNetworkConfig {
782                        all_override_networks: all_override_networks.clone(),
783                        pass_network: None,
784                    },
785                )
786                .await?;
787
788            // Override passes: one per annotated network.
789            for &network in &override_networks {
790                let mut pass_evm_opts = evm_opts.clone();
791                pass_evm_opts.networks = network.into();
792                let (_, pass_outcome) = self
793                    .dispatch_network(
794                        &pass_evm_opts,
795                        config.clone(),
796                        pass_evm_opts.clone(),
797                        output,
798                        filter,
799                        coverage,
800                        should_debug,
801                        decode_internal,
802                        MultiNetworkConfig {
803                            all_override_networks: all_override_networks.clone(),
804                            pass_network: Some(network),
805                        },
806                    )
807                    .await?;
808                merge_outcomes(&mut outcome, pass_outcome);
809            }
810
811            // Print the merged summary (per-pass summaries are suppressed in `run_tests_inner`).
812            // Machine mode emits a terminal envelope from the binary entry point instead.
813            if !self.summary && !shell::is_json() && !foundry_cli::is_machine() {
814                sh_println!("{}", outcome.summary(multi_pass_timer.elapsed()))?;
815            }
816            if self.summary && !outcome.results.is_empty() && !foundry_cli::is_machine() {
817                let summary_report = TestSummaryReport::new(self.detailed, outcome.clone());
818                sh_println!("{}", &summary_report)?;
819            }
820
821            (libraries, outcome)
822        };
823
824        if should_draw {
825            let (suite_name, test_name, mut test_result) =
826                outcome.remove_first().ok_or_eyre("no tests were executed")?;
827
828            let (_, arena) = test_result
829                .traces
830                .iter_mut()
831                .find(|(kind, _)| *kind == TraceKind::Execution)
832                .unwrap();
833
834            // Decode traces.
835            let decoder = outcome.last_run_decoder.as_ref().unwrap();
836            decode_trace_arena(arena, decoder).await;
837            let mut fst = folded_stack_trace::build(arena, self.evm.isolate);
838
839            let label = if self.flamegraph { "flamegraph" } else { "flamechart" };
840            let contract = suite_name.split(':').next_back().unwrap();
841            let test_name = test_name.trim_end_matches("()");
842            let file_name = format!("cache/{label}_{contract}_{test_name}.svg");
843            let file = std::fs::File::create(&file_name).wrap_err("failed to create file")?;
844            let file = std::io::BufWriter::new(file);
845
846            let mut options = inferno::flamegraph::Options::default();
847            options.title = format!("{label} {contract}::{test_name}");
848            options.count_name = "gas".to_string();
849            if self.flamechart {
850                options.flame_chart = true;
851                fst.reverse();
852            }
853
854            // Generate SVG.
855            inferno::flamegraph::from_lines(&mut options, fst.iter().map(String::as_str), file)
856                .wrap_err("failed to write svg")?;
857            sh_println!("Saved to {file_name}")?;
858
859            // Open SVG in default program.
860            if let Err(e) = opener::open(&file_name) {
861                sh_err!("Failed to open {file_name}; please open it manually: {e}")?;
862            }
863        }
864
865        if should_debug {
866            // Get first non-empty suite result. We will have only one such entry.
867            let (_, _, test_result) =
868                outcome.remove_first().ok_or_eyre("no tests were executed")?;
869
870            let sources =
871                ContractSources::from_project_output(output, project_root, Some(&libraries))?;
872
873            // Prefer execution traces for normal debug runs, but when execution never starts
874            // (for example if `setUp()` reverts), fall back to available setup/deployment traces.
875            let traces = {
876                let execution = test_result
877                    .traces
878                    .iter()
879                    .filter(|(kind, _)| kind.is_execution())
880                    .cloned()
881                    .collect::<Vec<_>>();
882                if execution.is_empty() { test_result.traces.clone() } else { execution }
883            };
884
885            // Run the debugger.
886            let mut builder = Debugger::builder()
887                .traces(traces)
888                .sources(sources)
889                .breakpoints(test_result.breakpoints);
890
891            if let Some(decoder) = &outcome.last_run_decoder {
892                builder = builder.decoder(decoder);
893            }
894
895            let mut debugger = builder.build();
896            if let Some(dump_path) = &self.dump {
897                debugger.dump_to_file(dump_path)?;
898            } else {
899                debugger.try_run_tui()?;
900            }
901        }
902
903        // All tests have been run once before reaching this point
904        if let Some(mutate) = &self.mutate {
905            // Check outcome here, stop if any test failed
906            if outcome.failed() > 0 {
907                eyre::bail!("Cannot run mutation testing with failed tests");
908            }
909
910            // A green baseline that ran zero non-skipped tests is not useful:
911            // every compileable mutant would be reported as `Alive` (no test
912            // failed, so nothing killed it), which produces a wildly
913            // misleading mutation report. Hard-error so users get an actual
914            // signal that their filter / path / setup matched nothing.
915            if outcome.successes().next().is_none() {
916                eyre::bail!(
917                    "Mutation testing requires at least one passing baseline test; the current \
918                     filter/path selection matched zero non-skipped tests. Loosen `--match-test` / \
919                     `--match-contract` / `--match-path` or check the project layout."
920                );
921            }
922
923            // Explicit paths on --mutate cannot be combined with the --mutate-path
924            // glob filter: clap can't express this directly because --mutate takes
925            // an optional list of paths.
926            if !mutate.is_empty() && self.mutate_path.is_some() {
927                eyre::bail!(
928                    "`--mutate-path <PATTERN>` cannot be combined with explicit paths passed to `--mutate`; pass either paths or a glob pattern, not both"
929                );
930            }
931
932            // The mutation runner builds a single-pass `MultiContractRunner`
933            // (`runner.rs::compile_and_test_inner`) and does not honor inline
934            // per-test network annotations. If the project declares network
935            // overrides, running mutation testing would silently execute those
936            // tests on the wrong network and produce false survivors / kills.
937            // Bail with a clear error rather than do the wrong thing silently.
938            if !override_networks.is_empty() {
939                eyre::bail!(
940                    "Mutation testing does not yet support inline per-test network overrides \
941                     (found {} annotated network(s)). Re-run without `--mutate` or remove the \
942                     per-test network annotations.",
943                    override_networks.len()
944                );
945            }
946
947            // The mutation runner symlinks dependency directories (`lib`,
948            // `node_modules`, `dependencies`) into each per-mutant TempDir for
949            // performance — see `workspace::copy_project`. That isolation
950            // breaks down if tests can write to those shared trees, either via
951            // `vm.writeFile` (broad `fs_permissions`) or arbitrary `ffi` calls.
952            // Detect both up front so users aren't surprised by races or
953            // corruption of their real dependency tree.
954            use foundry_config::fs_permissions::FsAccessPermission;
955            if config_for_mutation.ffi {
956                eyre::bail!(
957                    "Mutation testing is unsafe with `ffi = true`: per-mutant workspaces share \
958                     symlinked dependency directories, and arbitrary FFI commands run by tests \
959                     can race or corrupt the real `lib`/`node_modules`/`dependencies` trees. \
960                     Disable ffi in your foundry.toml to run mutation tests."
961                );
962            }
963
964            // Only refuse write-capable `fs_permissions` whose path can actually
965            // reach one of the symlinked dependency trees. Scoped writes (e.g.
966            // `./out`, `./snapshots`) are safe because they target paths that
967            // never resolve into the shared `lib`/`node_modules`/`dependencies`
968            // trees.
969            let root = &config_for_mutation.root;
970            let canonicalize_through_existing_ancestor = |path: &Path| -> PathBuf {
971                let resolved =
972                    if path.is_absolute() { path.to_path_buf() } else { root.join(path) };
973                if let Ok(canon) = dunce::canonicalize(&resolved) {
974                    return canon;
975                }
976
977                let mut missing = Vec::new();
978                let mut ancestor = resolved.as_path();
979                while !ancestor.exists() {
980                    let Some(name) = ancestor.file_name() else { break };
981                    missing.push(name.to_owned());
982                    let Some(parent) = ancestor.parent() else { break };
983                    ancestor = parent;
984                }
985
986                let mut canon = dunce::canonicalize(ancestor).unwrap_or_else(|_| ancestor.into());
987                for component in missing.iter().rev() {
988                    canon.push(component);
989                }
990                canon
991            };
992
993            let mut shared_dep_dirs: Vec<PathBuf> = config_for_mutation
994                .libs
995                .iter()
996                .filter(|p| p.exists())
997                .map(|p| canonicalize_through_existing_ancestor(p))
998                .collect();
999            for dep_dir in ["node_modules", "dependencies"] {
1000                let dep_path = root.join(dep_dir);
1001                if dep_path.exists() && dep_path.is_dir() {
1002                    shared_dep_dirs.push(canonicalize_through_existing_ancestor(&dep_path));
1003                }
1004            }
1005
1006            let effective_permission = |path: &Path| -> Option<FsAccessPermission> {
1007                let mut max_path_len = 0;
1008                let mut highest_permission = FsAccessPermission::None;
1009
1010                for perm in &config_for_mutation.fs_permissions.permissions {
1011                    let permission_path = canonicalize_through_existing_ancestor(&perm.path);
1012                    if path.starts_with(&permission_path) {
1013                        let path_len = permission_path.components().count();
1014                        if path_len > max_path_len {
1015                            max_path_len = path_len;
1016                            highest_permission = perm.access;
1017                        } else if path_len == max_path_len {
1018                            highest_permission = match (highest_permission, perm.access) {
1019                                (FsAccessPermission::ReadWrite, _)
1020                                | (FsAccessPermission::Read, FsAccessPermission::Write)
1021                                | (FsAccessPermission::Write, FsAccessPermission::Read) => {
1022                                    FsAccessPermission::ReadWrite
1023                                }
1024                                (FsAccessPermission::None, perm) => perm,
1025                                (existing_perm, _) => existing_perm,
1026                            };
1027                        }
1028                    }
1029                }
1030
1031                (max_path_len > 0).then_some(highest_permission)
1032            };
1033
1034            let grants_write = |path: &Path| {
1035                matches!(
1036                    effective_permission(path),
1037                    Some(FsAccessPermission::Write | FsAccessPermission::ReadWrite)
1038                )
1039            };
1040
1041            let unsafe_write_paths: Vec<&Path> = config_for_mutation
1042                .fs_permissions
1043                .permissions
1044                .iter()
1045                .filter(|perm| {
1046                    matches!(perm.access, FsAccessPermission::Write | FsAccessPermission::ReadWrite)
1047                })
1048                .filter(|perm| {
1049                    let perm_path = canonicalize_through_existing_ancestor(&perm.path);
1050                    shared_dep_dirs.iter().any(|dep| {
1051                        if perm_path.starts_with(dep) {
1052                            grants_write(&perm_path)
1053                        } else if dep.starts_with(&perm_path) {
1054                            grants_write(dep)
1055                        } else {
1056                            false
1057                        }
1058                    })
1059                })
1060                .map(|p| p.path.as_path())
1061                .collect();
1062
1063            if !unsafe_write_paths.is_empty() {
1064                let paths = unsafe_write_paths
1065                    .iter()
1066                    .map(|p| format!("  - {}", p.display()))
1067                    .collect::<Vec<_>>()
1068                    .join("\n");
1069                eyre::bail!(
1070                    "Mutation testing is unsafe with write-capable `fs_permissions` that can \
1071                     reach the symlinked dependency trees (`lib`/`node_modules`/`dependencies`); \
1072                     per-mutant workspaces share those trees, so `vm.writeFile` calls would race \
1073                     against or corrupt your real dependencies. Restrict the following \
1074                     `fs_permissions` entries to read-only or scope them away from dependency \
1075                     paths:\n{paths}"
1076                );
1077            }
1078
1079            let json_output = shell::is_json();
1080            let selected_sources_relative = self
1081                .get_sources_to_compile(&config_for_mutation, filter)?
1082                .into_iter()
1083                .filter_map(|path| {
1084                    path.strip_prefix(&config_for_mutation.root).ok().map(PathBuf::from)
1085                })
1086                .collect::<Vec<_>>();
1087
1088            let mutation_config = MutationRunConfig {
1089                mutate_paths: mutate.clone(),
1090                mutate_path_pattern: self.mutate_path.clone(),
1091                mutate_contract_pattern: self.mutate_contract.clone(),
1092                num_workers: self.mutation_jobs.unwrap_or(0),
1093                show_progress: self.show_progress,
1094                json_output,
1095                // Carry the same filter args (--match-test, --match-contract,
1096                // --match-path, positional path shorthand, --rerun, ...) and
1097                // isolation flag the baseline actually used, so every mutant
1098                // exercises the exact same test set under the same execution
1099                // model. We pull from the materialized `filter`, not the raw
1100                // CLI flags on `self`, because the baseline applies extras:
1101                // the positional `forge test <path>` shorthand is folded into
1102                // `path_pattern`, and `--rerun` injects last-run failures
1103                // into `test_pattern`. Using `self.filter.clone()` would lose
1104                // those and let mutant runs silently diverge from baseline.
1105                filter_args: filter.args().clone(),
1106                selected_sources_relative,
1107                isolate: evm_opts_for_mutation.isolate,
1108            };
1109
1110            let result = run_mutation_testing(
1111                Arc::new(config_for_mutation.clone()),
1112                output,
1113                evm_opts_for_mutation.clone(),
1114                mutation_config,
1115            )
1116            .await?;
1117
1118            if result.cancelled {
1119                std::process::exit(130);
1120            }
1121
1122            // Output JSON if requested
1123            if json_output {
1124                let json_output = result.summary.to_json_output(result.duration_secs);
1125                sh_println!("{}", serde_json::to_string(&json_output)?)?;
1126            }
1127
1128            outcome = TestOutcome::empty(None, true);
1129        }
1130
1131        Ok(outcome)
1132    }
1133
1134    /// Build the test runner and execute tests for a specific network type.
1135    #[allow(clippy::too_many_arguments)]
1136    async fn build_and_run_tests<FEN: FoundryEvmNetwork>(
1137        &self,
1138        config: Config,
1139        evm_opts: EvmOpts,
1140        output: &ProjectCompileOutput,
1141        filter: &ProjectPathsAwareFilter,
1142        coverage: bool,
1143        should_debug: bool,
1144        decode_internal: InternalTraceMode,
1145        multi_network: MultiNetworkConfig,
1146    ) -> eyre::Result<(Libraries, TestOutcome)> {
1147        let verbosity = evm_opts.verbosity;
1148        let (evm_env, tx_env, fork_block) =
1149            evm_opts.env::<SpecFor<FEN>, BlockEnvFor<FEN>, TxEnvFor<FEN>>().await?;
1150
1151        let config = Arc::new(config);
1152        let showmap = self.showmap_config();
1153        let runner = MultiContractRunnerBuilder::new(config.clone())
1154            .set_debug(should_debug)
1155            .set_decode_internal(decode_internal)
1156            .initial_balance(evm_opts.initial_balance)
1157            .sender(evm_opts.sender)
1158            .with_fork(evm_opts.get_fork(&config, evm_env.cfg_env.chain_id, fork_block))
1159            .enable_isolation(evm_opts.isolate)
1160            .fail_fast(self.fail_fast)
1161            .set_coverage(coverage)
1162            .with_multi_network(multi_network)
1163            .with_showmap(showmap)
1164            .build::<FEN, MultiCompiler>(output, evm_env, tx_env, evm_opts)?;
1165
1166        let libraries = runner.libraries.clone();
1167        let outcome = self.run_tests_inner(runner, config, verbosity, filter, output).await?;
1168        Ok((libraries, outcome))
1169    }
1170
1171    /// Dispatches `build_and_run_tests` to the correct network type based on `evm_opts.networks`.
1172    #[allow(clippy::too_many_arguments)]
1173    async fn dispatch_network(
1174        &self,
1175        dispatch_opts: &EvmOpts,
1176        config: Config,
1177        evm_opts: EvmOpts,
1178        output: &ProjectCompileOutput,
1179        filter: &ProjectPathsAwareFilter,
1180        coverage: bool,
1181        should_debug: bool,
1182        decode_internal: InternalTraceMode,
1183        multi_network: MultiNetworkConfig,
1184    ) -> eyre::Result<(Libraries, TestOutcome)> {
1185        if dispatch_opts.networks.is_tempo() {
1186            self.build_and_run_tests::<TempoEvmNetwork>(
1187                config,
1188                evm_opts,
1189                output,
1190                filter,
1191                coverage,
1192                should_debug,
1193                decode_internal,
1194                multi_network,
1195            )
1196            .await
1197        } else {
1198            #[cfg(feature = "optimism")]
1199            if dispatch_opts.networks.is_optimism() {
1200                return self
1201                    .build_and_run_tests::<OpEvmNetwork>(
1202                        config,
1203                        evm_opts,
1204                        output,
1205                        filter,
1206                        coverage,
1207                        should_debug,
1208                        decode_internal,
1209                        multi_network,
1210                    )
1211                    .await;
1212            }
1213            self.build_and_run_tests::<EthEvmNetwork>(
1214                config,
1215                evm_opts,
1216                output,
1217                filter,
1218                coverage,
1219                should_debug,
1220                decode_internal,
1221                multi_network,
1222            )
1223            .await
1224        }
1225    }
1226
1227    /// Run all tests that matches the filter predicate from a test runner
1228    async fn run_tests_inner<FEN: FoundryEvmNetwork>(
1229        &self,
1230        mut runner: MultiContractRunner<FEN>,
1231        config: Arc<Config>,
1232        verbosity: u8,
1233        filter: &ProjectPathsAwareFilter,
1234        output: &ProjectCompileOutput,
1235    ) -> eyre::Result<TestOutcome> {
1236        let fuzz_seed = config.fuzz.seed;
1237        if self.list {
1238            return list(runner, filter);
1239        }
1240
1241        trace!(target: "forge::test", "running all tests");
1242
1243        let machine_mode = foundry_cli::is_machine();
1244
1245        // If we need to render to a serialized format, we should not print anything else to stdout.
1246        // Machine mode is also a structured stream and must not interleave human output.
1247        let silent = machine_mode
1248            || self.gas_report && shell::is_json()
1249            || self.summary && shell::is_json()
1250            || self.mutate.is_some() && shell::is_json();
1251
1252        let num_filtered = runner.matching_test_functions(filter).count();
1253
1254        if num_filtered == 0 {
1255            let total_tests = if filter.is_empty() {
1256                num_filtered
1257            } else {
1258                runner.matching_test_functions(&EmptyTestFilter::default()).count()
1259            };
1260            if !machine_mode {
1261                if total_tests == 0 {
1262                    sh_println!(
1263                        "No tests found in project! Forge looks for functions that start with `test`"
1264                    )?;
1265                } else {
1266                    let mut msg = format!("no tests match the provided pattern:\n{filter}");
1267                    // Try to suggest a test when there's no match.
1268                    if let Some(test_pattern) = &filter.args().test_pattern {
1269                        let test_name = test_pattern.as_str();
1270                        // Filter contracts but not test functions.
1271                        let candidates = runner.all_test_functions(filter).map(|f| &f.name);
1272                        if let Some(suggestion) = utils::did_you_mean(test_name, candidates).pop() {
1273                            write!(msg, "\nDid you mean `{suggestion}`?")?;
1274                        }
1275                    }
1276                    sh_warn!("{msg}")?;
1277                }
1278            }
1279            return Ok(TestOutcome::empty(Some(runner.known_contracts.clone()), false));
1280        }
1281
1282        if num_filtered != 1 && (self.debug || self.flamegraph || self.flamechart) {
1283            let action = if self.flamegraph {
1284                "generate a flamegraph"
1285            } else if self.flamechart {
1286                "generate a flamechart"
1287            } else {
1288                "run the debugger"
1289            };
1290            let filter = if filter.is_empty() {
1291                String::new()
1292            } else {
1293                format!("\n\nFilter used:\n{filter}")
1294            };
1295            eyre::bail!(
1296                "{num_filtered} tests matched your criteria, but exactly 1 test must match in order to {action}.\n\n\
1297                 Use --match-contract and --match-path to further limit the search.{filter}",
1298            );
1299        }
1300
1301        // If exactly one test matched, we enable full tracing.
1302        if num_filtered == 1 && self.decode_internal {
1303            runner.decode_internal = InternalTraceMode::Full;
1304        }
1305
1306        // Run tests in a non-streaming fashion and collect results for serialization.
1307        // Agent stream wins over `--json`.
1308        if self.mutate.is_none()
1309            && !machine_mode
1310            && !self.gas_report
1311            && !self.summary
1312            && shell::is_json()
1313        {
1314            let mut results = runner.test_collect(filter)?;
1315            for suite_result in results.values_mut() {
1316                for test_result in suite_result.test_results.values_mut() {
1317                    if verbosity >= 2 {
1318                        // Decode logs at level 2 and above.
1319                        test_result.decoded_logs = decode_console_logs(&test_result.logs);
1320                    } else {
1321                        // Empty logs for non verbose runs.
1322                        test_result.logs = vec![];
1323                    }
1324                }
1325            }
1326            sh_println!("{}", serde_json::to_string(&results)?)?;
1327            let kc = runner.known_contracts.clone();
1328            return Ok(TestOutcome::new(Some(kc), results, self.allow_failure, fuzz_seed));
1329        }
1330
1331        if self.junit {
1332            let results = runner.test_collect(filter)?;
1333            sh_println!("{}", junit_xml_report(&results, verbosity).to_string()?)?;
1334            let kc = runner.known_contracts.clone();
1335            return Ok(TestOutcome::new(Some(kc), results, self.allow_failure, fuzz_seed));
1336        }
1337
1338        let remote_chain =
1339            if runner.fork.is_some() { runner.tx_env.chain_id().map(Into::into) } else { None };
1340        let known_contracts = runner.known_contracts.clone();
1341
1342        let libraries = runner.libraries.clone();
1343
1344        // Capture multi-pass state before moving `runner` into the spawn task.
1345        // In multi-pass mode the per-pass summary is suppressed; the merged summary is
1346        // printed once by the caller after all passes complete.
1347        let is_multi_pass = !runner.tcfg.multi_network.all_override_networks.is_empty();
1348        let is_tempo_network = runner.tcfg.evm_opts.networks.is_tempo();
1349
1350        // Run tests in a streaming fashion.
1351        let (tx, rx) = channel::<(String, SuiteResult)>();
1352        let timer = Instant::now();
1353        let show_progress = config.show_progress;
1354        let handle = tokio::task::spawn_blocking({
1355            let filter = filter.clone();
1356            move || runner.test(&filter, tx, show_progress).map(|()| runner)
1357        });
1358
1359        // Set up trace identifiers.
1360        let mut identifier = TraceIdentifiers::new().with_local(&known_contracts);
1361
1362        // Avoid using external identifiers for gas report as we decode more traces and this will be
1363        // expensive. Also skip external identifiers for local tests (no remote chain) to avoid
1364        // unnecessary Etherscan API calls that significantly slow down test execution.
1365        if !self.gas_report && remote_chain.is_some() {
1366            identifier = identifier.with_external(&config, remote_chain)?;
1367        }
1368
1369        // Build the trace decoder.
1370        let mut builder = CallTraceDecoderBuilder::new()
1371            .with_known_contracts(&known_contracts)
1372            .with_label_disabled(self.disable_labels)
1373            .with_verbosity(verbosity)
1374            .with_chain_id(remote_chain.map(|c| c.id()))
1375            .with_tempo_hardfork(
1376                (is_tempo_network || remote_chain.is_some_and(|chain| chain.is_tempo()))
1377                    .then(|| config.evm_spec_id::<TempoHardfork>()),
1378            );
1379        // Signatures are of no value for gas reports.
1380        if !self.gas_report {
1381            builder =
1382                builder.with_signature_identifier(SignaturesIdentifier::from_config(&config)?);
1383        }
1384
1385        if self.decode_internal {
1386            let sources =
1387                ContractSources::from_project_output(output, &config.root, Some(&libraries))?;
1388            builder = builder.with_debug_identifier(DebugTraceIdentifier::new(sources));
1389        }
1390        let mut decoder = builder.build();
1391
1392        let mut gas_report = self.gas_report.then(|| {
1393            GasReport::new(
1394                config.gas_reports.clone(),
1395                config.gas_reports_ignore.clone(),
1396                config.gas_reports_include_tests,
1397            )
1398        });
1399
1400        let mut gas_snapshots = BTreeMap::<String, BTreeMap<String, String>>::new();
1401
1402        let mut outcome = TestOutcome::empty(None, self.allow_failure);
1403        outcome.fuzz_seed = fuzz_seed;
1404
1405        let mut any_test_failed = false;
1406        let mut backtrace_builder = None;
1407        for (contract_name, mut suite_result) in rx {
1408            let len = suite_result.len();
1409            let tests = &mut suite_result.test_results;
1410            let has_tests = !tests.is_empty();
1411
1412            // In multi-pass (per-test network override) mode, skip suites that contributed no
1413            // tests to this pass so we don't emit a stray blank line in the suite header or
1414            // pollute the outcome with empty entries.
1415            if is_multi_pass && !has_tests && suite_result.warnings.is_empty() {
1416                continue;
1417            }
1418
1419            // Clear the addresses and labels from previous test.
1420            decoder.clear_addresses();
1421
1422            // We identify addresses if we're going to print *any* trace or gas report.
1423            let identify_addresses = verbosity >= 3
1424                || self.gas_report
1425                || self.debug
1426                || self.flamegraph
1427                || self.flamechart;
1428
1429            // Print suite header.
1430            if !silent {
1431                sh_println!()?;
1432                for warning in &suite_result.warnings {
1433                    sh_warn!("{warning}")?;
1434                }
1435                if has_tests {
1436                    let tests = if len > 1 { "tests" } else { "test" };
1437                    sh_println!("Ran {len} {tests} for {contract_name}")?;
1438                }
1439            }
1440
1441            // Process individual test results, printing logs and traces when necessary.
1442            for (name, result) in tests {
1443                let show_traces =
1444                    !self.suppress_successful_traces || result.status == TestStatus::Failure;
1445                if !silent {
1446                    sh_println!("{}", result.short_result_with_suite(name, &contract_name))?;
1447
1448                    // Display invariant metrics if invariant kind.
1449                    if let TestKind::Invariant { metrics, .. } = &result.kind
1450                        && !metrics.is_empty()
1451                    {
1452                        let _ = sh_println!("\n{}\n", format_invariant_metrics_table(metrics));
1453                    }
1454
1455                    // We only display logs at level 2 and above
1456                    if verbosity >= 2 && show_traces {
1457                        // We only decode logs from Hardhat and DS-style console events
1458                        let console_logs = decode_console_logs(&result.logs);
1459                        if !console_logs.is_empty() {
1460                            sh_println!("Logs:")?;
1461                            for log in console_logs {
1462                                sh_println!("  {log}")?;
1463                            }
1464                            sh_println!()?;
1465                        }
1466                    }
1467                }
1468
1469                if machine_mode {
1470                    emit_test_result_event(&contract_name, name, result)?;
1471                }
1472
1473                // We shouldn't break out of the outer loop directly here so that we finish
1474                // processing the remaining tests and print the suite summary.
1475                any_test_failed |= result.status == TestStatus::Failure;
1476
1477                // Clear the addresses and labels from previous runs.
1478                decoder.clear_addresses();
1479                decoder.labels.extend(result.labels.iter().map(|(k, v)| (*k, v.clone())));
1480
1481                // Identify addresses and decode traces.
1482                let mut decoded_traces = Vec::with_capacity(result.traces.len());
1483                for (kind, arena) in &mut result.traces {
1484                    if identify_addresses {
1485                        if self.debug && !result.debug_bytecodes.is_empty() {
1486                            let mut local_identifier = TraceIdentifiers::new()
1487                                .with_local_and_bytecodes(
1488                                    &known_contracts,
1489                                    &result.debug_bytecodes,
1490                                );
1491                            decoder.identify(arena, &mut local_identifier);
1492                        }
1493                        decoder.identify(arena, &mut identifier);
1494                    }
1495
1496                    // verbosity:
1497                    // - 0..3: nothing
1498                    // - 3: only display traces for failed tests
1499                    // - 4: also display the setup trace for failed tests
1500                    // - 5..: display all traces for all tests, including storage changes
1501                    let should_include = match kind {
1502                        TraceKind::Execution => {
1503                            (verbosity == 3 && result.status.is_failure()) || verbosity >= 4
1504                        }
1505                        TraceKind::Setup => {
1506                            (verbosity == 4 && result.status.is_failure()) || verbosity >= 5
1507                        }
1508                        TraceKind::Deployment => false,
1509                    };
1510
1511                    if should_include {
1512                        decode_trace_arena(arena, &decoder).await;
1513
1514                        if let Some(trace_depth) = self.trace_depth {
1515                            prune_trace_depth(arena, trace_depth);
1516                        }
1517
1518                        decoded_traces.push(render_trace_arena_inner(arena, false, verbosity > 4));
1519                    }
1520                }
1521
1522                if !silent && show_traces && !decoded_traces.is_empty() {
1523                    sh_println!("Traces:")?;
1524                    for trace in &decoded_traces {
1525                        sh_println!("{trace}")?;
1526                    }
1527                }
1528
1529                // Extract and display backtrace for failed tests when verbosity >= 3.
1530                // At verbosity 3-4 backtraces show contract/function names only.
1531                // At verbosity 5 backtraces include source file locations.
1532                if !silent
1533                    && result.status.is_failure()
1534                    && verbosity >= 3
1535                    && !result.traces.is_empty()
1536                    && let Some((_, arena)) =
1537                        result.traces.iter().find(|(kind, _)| matches!(kind, TraceKind::Execution))
1538                {
1539                    // Lazily initialize the backtrace builder on first failure
1540                    let builder = backtrace_builder.get_or_insert_with(|| {
1541                        BacktraceBuilder::new(
1542                            output,
1543                            config.root.clone(),
1544                            config.parsed_libraries().ok(),
1545                            config.via_ir,
1546                        )
1547                    });
1548
1549                    let backtrace = builder.from_traces(arena);
1550
1551                    if !backtrace.is_empty() {
1552                        sh_println!("{}", backtrace)?;
1553                    }
1554                }
1555
1556                if let Some(gas_report) = &mut gas_report {
1557                    gas_report.analyze(result.traces.iter().map(|(_, a)| &a.arena), &decoder).await;
1558
1559                    for trace in &result.gas_report_traces {
1560                        decoder.clear_addresses();
1561
1562                        // Re-execute setup and deployment traces to collect identities created in
1563                        // setUp and constructor.
1564                        for (kind, arena) in &result.traces {
1565                            if !matches!(kind, TraceKind::Execution) {
1566                                decoder.identify(arena, &mut identifier);
1567                            }
1568                        }
1569
1570                        for arena in trace {
1571                            decoder.identify(arena, &mut identifier);
1572                            gas_report.analyze([arena], &decoder).await;
1573                        }
1574                    }
1575                }
1576                // Clear memory.
1577                result.gas_report_traces = Default::default();
1578
1579                // Collect and merge gas snapshots.
1580                for (group, new_snapshots) in &result.gas_snapshots {
1581                    gas_snapshots.entry(group.clone()).or_default().extend(new_snapshots.clone());
1582                }
1583            }
1584
1585            // Write gas snapshots to disk if any were collected.
1586            if !gas_snapshots.is_empty() {
1587                // By default `gas_snapshot_check` is set to `false` in the config.
1588                //
1589                // The user can either:
1590                // - Set `FORGE_SNAPSHOT_CHECK=true` in the environment.
1591                // - Pass `--gas-snapshot-check=true` as a CLI argument.
1592                // - Set `gas_snapshot_check = true` in the config.
1593                //
1594                // If the user passes `--gas-snapshot-check=<bool>` then it will override the config
1595                // and the environment variable, disabling the check if `false` is passed.
1596                //
1597                // Exiting early with code 1 if differences are found.
1598                if self.gas_snapshot_check.unwrap_or(config.gas_snapshot_check) {
1599                    let differences_found =
1600                        gas_snapshots.iter().fold(false, |mut found, (group, snapshots)| {
1601                            // If the snapshot file doesn't exist, we can't compare so we skip.
1602                            if !&config.snapshots.join(format!("{group}.json")).exists() {
1603                                return found;
1604                            }
1605
1606                            let previous_snapshots: BTreeMap<String, String> =
1607                                fs::read_json_file(&config.snapshots.join(format!("{group}.json")))
1608                                    .expect("Failed to read snapshots from disk");
1609
1610                            let diff: BTreeMap<_, _> = snapshots
1611                                .iter()
1612                                .filter_map(|(k, v)| {
1613                                    previous_snapshots.get(k).and_then(|previous_snapshot| {
1614                                        (previous_snapshot != v).then(|| {
1615                                            (k.clone(), (previous_snapshot.clone(), v.clone()))
1616                                        })
1617                                    })
1618                                })
1619                                .collect();
1620
1621                            if !diff.is_empty() {
1622                                let _ = sh_eprintln!(
1623                                    "{}",
1624                                    format!("\n[{group}] Failed to match snapshots:").red().bold()
1625                                );
1626
1627                                for (key, (previous_snapshot, snapshot)) in &diff {
1628                                    let _ = sh_eprintln!(
1629                                        "{}",
1630                                        format!("- [{key}] {previous_snapshot} → {snapshot}").red()
1631                                    );
1632                                }
1633
1634                                found = true;
1635                            }
1636
1637                            found
1638                        });
1639
1640                    if differences_found {
1641                        sh_eprintln!()?;
1642                        eyre::bail!("Snapshots differ from previous run");
1643                    }
1644                }
1645
1646                // By default `gas_snapshot_emit` is set to `true` in the config.
1647                //
1648                // The user can either:
1649                // - Set `FORGE_SNAPSHOT_EMIT=false` in the environment.
1650                // - Pass `--gas-snapshot-emit=false` as a CLI argument.
1651                // - Set `gas_snapshot_emit = false` in the config.
1652                //
1653                // If the user passes `--gas-snapshot-emit=<bool>` then it will override the config
1654                // and the environment variable, enabling the check if `true` is passed.
1655                if self.gas_snapshot_emit.unwrap_or(config.gas_snapshot_emit) {
1656                    // Create `snapshots` directory if it doesn't exist.
1657                    fs::create_dir_all(&config.snapshots)?;
1658
1659                    // Write gas snapshots to disk per group.
1660                    for (group, snapshots) in &gas_snapshots {
1661                        fs::write_pretty_json_file(
1662                            &config.snapshots.join(format!("{group}.json")),
1663                            &snapshots,
1664                        )
1665                        .expect("Failed to write gas snapshots to disk");
1666                    }
1667                }
1668            }
1669
1670            // Print suite summary.
1671            if !silent && has_tests {
1672                sh_println!("{}", suite_result.summary())?;
1673            }
1674
1675            if machine_mode {
1676                for warning in &suite_result.warnings {
1677                    emit_warning_event(&contract_name, warning)?;
1678                }
1679                // Terminator follows any record for the group; warning-only
1680                // suites get a zero-count `suite_finished`.
1681                if has_tests || !suite_result.warnings.is_empty() {
1682                    emit_suite_finished_event(&contract_name, &suite_result)?;
1683                }
1684            }
1685
1686            // Add the suite result to the outcome.
1687            outcome.results.insert(contract_name, suite_result);
1688
1689            // Stop processing the remaining suites if any test failed and `fail_fast` is set.
1690            if self.fail_fast && any_test_failed {
1691                break;
1692            }
1693        }
1694        outcome.last_run_decoder = Some(decoder);
1695        let duration = timer.elapsed();
1696
1697        trace!(target: "forge::test", len=outcome.results.len(), %any_test_failed, "done with results");
1698
1699        if let Some(gas_report) = gas_report {
1700            let finalized = gas_report.finalize();
1701            sh_println!("{finalized}")?;
1702            outcome.gas_report = Some(finalized);
1703        }
1704
1705        if !is_multi_pass && !self.summary && !shell::is_json() && !machine_mode {
1706            sh_println!("{}", outcome.summary(duration))?;
1707        }
1708
1709        if !is_multi_pass && self.summary && !outcome.results.is_empty() && !machine_mode {
1710            let summary_report = TestSummaryReport::new(self.detailed, outcome.clone());
1711            sh_println!("{summary_report}")?;
1712        }
1713
1714        // Reattach the task.
1715        match handle.await {
1716            Ok(result) => {
1717                let runner = result?;
1718                outcome.known_contracts = Some(runner.known_contracts);
1719            }
1720            Err(e) => match e.try_into_panic() {
1721                Ok(payload) => std::panic::resume_unwind(payload),
1722                Err(e) => return Err(e.into()),
1723            },
1724        }
1725
1726        // Persist test run failures to enable replaying.
1727        persist_run_failures(&config, &outcome);
1728
1729        Ok(outcome)
1730    }
1731
1732    /// Returns the flattened [`FilterArgs`] arguments merged with [`Config`].
1733    /// Loads and applies filter from file if only last test run failures performed.
1734    pub fn filter(&self, config: &Config) -> Result<ProjectPathsAwareFilter> {
1735        let mut filter = self.filter.clone();
1736        let rerun_failures = if self.rerun {
1737            let failures = last_run_failures(config);
1738            filter.test_pattern = failures.test_pattern;
1739            failures.failures
1740        } else {
1741            None
1742        };
1743        if filter.path_pattern.is_some() {
1744            if self.path.is_some() {
1745                bail!("Can not supply both --match-path and |path|");
1746            }
1747        } else {
1748            filter.path_pattern = self.path.clone();
1749        }
1750        let mut filter = filter.merge_with_config(config);
1751        if let Some(failures) = rerun_failures {
1752            filter.set_rerun_failures(failures);
1753        }
1754        Ok(filter)
1755    }
1756
1757    /// Returns whether `BuildArgs` was configured with `--watch`
1758    pub const fn is_watch(&self) -> bool {
1759        self.watch.watch.is_some()
1760    }
1761
1762    /// Returns the [`watchexec::Config`] necessary to bootstrap a new watch loop.
1763    pub(crate) fn watchexec_config(&self) -> Result<watchexec::Config> {
1764        self.watch.watchexec_config(|| {
1765            let config = self.load_config()?;
1766            Ok([config.src, config.test])
1767        })
1768    }
1769}
1770
1771/// Terminal `forge test` envelope payload under `--machine`. Counts are
1772/// aggregated across every suite; times are in milliseconds.
1773#[derive(Clone, Debug, serde::Serialize)]
1774pub struct TestSummaryData {
1775    pub suites: usize,
1776    pub passed: usize,
1777    pub failed: usize,
1778    pub skipped: usize,
1779    pub duration_ms: u128,
1780}
1781
1782impl TestSummaryData {
1783    pub fn from_outcome(outcome: &TestOutcome, wall_clock: Duration) -> Self {
1784        Self {
1785            suites: outcome.results.len(),
1786            passed: outcome.passed(),
1787            failed: outcome.failed(),
1788            skipped: outcome.skipped(),
1789            duration_ms: wall_clock.as_millis(),
1790        }
1791    }
1792}
1793
1794#[derive(serde::Serialize)]
1795struct TestResultEvent<'a> {
1796    suite: &'a str,
1797    name: &'a str,
1798    status: &'static str,
1799    #[serde(skip_serializing_if = "Option::is_none")]
1800    reason: Option<&'a str>,
1801    duration_ms: u128,
1802}
1803
1804#[derive(serde::Serialize)]
1805struct SuiteFinishedEvent<'a> {
1806    suite: &'a str,
1807    passed: usize,
1808    failed: usize,
1809    skipped: usize,
1810    duration_ms: u128,
1811}
1812
1813#[derive(serde::Serialize)]
1814struct WarningEvent<'a> {
1815    suite: &'a str,
1816    code: &'static str,
1817    message: &'a str,
1818}
1819
1820const fn status_str(status: TestStatus) -> &'static str {
1821    match status {
1822        TestStatus::Success => "passed",
1823        TestStatus::Failure => "failed",
1824        TestStatus::Skipped => "skipped",
1825    }
1826}
1827
1828fn emit_test_result_event(
1829    suite: &str,
1830    name: &str,
1831    result: &crate::result::TestResult,
1832) -> Result<()> {
1833    foundry_cli::json::print_stream_record(
1834        crate::introspect::TEST_EVENT_SCHEMA,
1835        "forge.test",
1836        "test_result",
1837        TestResultEvent {
1838            suite,
1839            name,
1840            status: status_str(result.status),
1841            reason: result.reason.as_deref(),
1842            duration_ms: result.duration.as_millis(),
1843        },
1844    )?;
1845    Ok(())
1846}
1847
1848fn emit_suite_finished_event(suite: &str, result: &SuiteResult) -> Result<()> {
1849    foundry_cli::json::print_stream_record(
1850        crate::introspect::TEST_EVENT_SCHEMA,
1851        "forge.test",
1852        "suite_finished",
1853        SuiteFinishedEvent {
1854            suite,
1855            passed: result.passed(),
1856            failed: result.failed(),
1857            skipped: result.skipped(),
1858            duration_ms: result.duration.as_millis(),
1859        },
1860    )?;
1861    Ok(())
1862}
1863
1864fn emit_warning_event(suite: &str, message: &str) -> Result<()> {
1865    foundry_cli::json::print_stream_record(
1866        crate::introspect::TEST_EVENT_SCHEMA,
1867        "forge.test",
1868        "warning",
1869        WarningEvent { suite, code: foundry_cli::diagnostic::test::WARNING, message },
1870    )?;
1871    Ok(())
1872}
1873
1874/// Emit a `compiler.solc.error` envelope and exit `Build (4)`. Shared by the
1875/// precompile and main-compile sites under `--machine`.
1876fn emit_machine_compile_error(output: &ProjectCompileOutput) -> ! {
1877    let errors: Vec<JsonMessage> = output
1878        .output()
1879        .errors
1880        .iter()
1881        .filter(|e| e.is_error())
1882        .map(|e| JsonMessage::error(SOLC_ERROR, e.to_string()))
1883        .collect();
1884    // Best-effort: bubbling on a broken stdout would demote exit `4` to `1`.
1885    let _ = print_json(&JsonEnvelope::<()>::failure(errors));
1886    std::process::exit(ExitCode::Build.to_i32());
1887}
1888
1889impl Provider for TestArgs {
1890    fn metadata(&self) -> Metadata {
1891        Metadata::named("Core Build Args Provider")
1892    }
1893
1894    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
1895        let mut dict = Dict::default();
1896
1897        let mut fuzz_dict = Dict::default();
1898        if let Some(fuzz_seed) = self.fuzz_seed {
1899            fuzz_dict.insert("seed".to_string(), fuzz_seed.to_string().into());
1900        }
1901        if let Some(fuzz_runs) = self.fuzz_runs {
1902            fuzz_dict.insert("runs".to_string(), fuzz_runs.into());
1903        }
1904        if let Some(fuzz_run) = self.fuzz_run {
1905            fuzz_dict.insert("run".to_string(), fuzz_run.into());
1906        }
1907        if let Some(fuzz_worker) = self.fuzz_worker {
1908            fuzz_dict.insert("worker".to_string(), fuzz_worker.into());
1909        }
1910        if let Some(fuzz_timeout) = self.fuzz_timeout {
1911            fuzz_dict.insert("timeout".to_string(), fuzz_timeout.into());
1912        }
1913        if let Some(fuzz_input_file) = self.fuzz_input_file.clone() {
1914            fuzz_dict.insert("failure_persist_file".to_string(), fuzz_input_file.into());
1915        }
1916        dict.insert("fuzz".to_string(), fuzz_dict.into());
1917
1918        if let Some(invariant_workers) = self.invariant_workers {
1919            dict.insert(
1920                "invariant".to_string(),
1921                Dict::from([("workers".to_string(), Value::serialize(invariant_workers)?)]).into(),
1922            );
1923        }
1924
1925        let mut symbolic_dict = Dict::default();
1926        if self.symbolic {
1927            symbolic_dict.insert("enabled".to_string(), true.into());
1928        }
1929        if let Some(solver) = self.symbolic_solver.clone() {
1930            symbolic_dict.insert("solver".to_string(), solver.into());
1931        }
1932        if let Some(solver_command) = self.symbolic_solver_command.clone() {
1933            symbolic_dict.insert("solver_command".to_string(), solver_command.into());
1934        }
1935        if let Some(solver_portfolio) = self.symbolic_solver_portfolio.clone() {
1936            symbolic_dict.insert("solver_portfolio".to_string(), solver_portfolio.into());
1937        }
1938        if let Some(timeout) = self.symbolic_timeout {
1939            symbolic_dict.insert("timeout".to_string(), timeout.into());
1940        }
1941        if let Some(loop_bound) = self.symbolic_loop {
1942            symbolic_dict.insert("loop".to_string(), loop_bound.into());
1943        }
1944        if let Some(depth) = self.symbolic_depth {
1945            symbolic_dict.insert("depth".to_string(), depth.into());
1946        }
1947        if let Some(width) = self.symbolic_width {
1948            symbolic_dict.insert("width".to_string(), width.into());
1949        }
1950        if let Some(max_depth) = self.symbolic_max_depth {
1951            symbolic_dict.insert("max_depth".to_string(), max_depth.into());
1952        }
1953        if let Some(max_paths) = self.symbolic_max_paths {
1954            symbolic_dict.insert("max_paths".to_string(), max_paths.into());
1955        }
1956        if let Some(invariant_depth) = self.symbolic_invariant_depth {
1957            symbolic_dict.insert("invariant_depth".to_string(), invariant_depth.into());
1958        }
1959        if let Some(max_solver_queries) = self.symbolic_max_solver_queries {
1960            symbolic_dict.insert("max_solver_queries".to_string(), max_solver_queries.into());
1961        }
1962        if let Some(default_dynamic_length) = self.symbolic_default_dynamic_length {
1963            symbolic_dict
1964                .insert("default_dynamic_length".to_string(), default_dynamic_length.into());
1965        }
1966        if let Some(max_dynamic_length) = self.symbolic_max_dynamic_length {
1967            symbolic_dict.insert("max_dynamic_length".to_string(), max_dynamic_length.into());
1968        }
1969        if let Some(array_lengths) = self.symbolic_array_lengths.clone() {
1970            symbolic_dict.insert("array_lengths".to_string(), array_lengths.into());
1971        }
1972        if let Some(max_calldata_bytes) = self.symbolic_max_calldata_bytes {
1973            symbolic_dict.insert("max_calldata_bytes".to_string(), max_calldata_bytes.into());
1974        }
1975        if self.symbolic_call_targets {
1976            symbolic_dict.insert("symbolic_call_targets".to_string(), true.into());
1977        }
1978        if self.symbolic_dump_smt {
1979            symbolic_dict.insert("dump_smt".to_string(), true.into());
1980        }
1981        if let Some(storage_layout) = self.symbolic_storage_layout.clone() {
1982            symbolic_dict.insert("storage_layout".to_string(), storage_layout.into());
1983        }
1984        dict.insert("symbolic".to_string(), symbolic_dict.into());
1985
1986        if let Some(etherscan_api_key) =
1987            self.etherscan_api_key.as_ref().filter(|s| !s.trim().is_empty())
1988        {
1989            dict.insert("etherscan_api_key".to_string(), etherscan_api_key.clone().into());
1990        }
1991
1992        if self.show_progress {
1993            dict.insert("show_progress".to_string(), true.into());
1994        }
1995
1996        // Mutation-testing CLI overrides
1997        if let Some(timeout) = self.mutation_timeout {
1998            let mut mutation_dict = Dict::default();
1999            mutation_dict.insert("timeout".to_string(), timeout.into());
2000            dict.insert("mutation".to_string(), mutation_dict.into());
2001        }
2002
2003        Ok(Map::from([(Config::selected_profile(), dict)]))
2004    }
2005}
2006
2007/// Lists all matching tests
2008fn list<FEN: FoundryEvmNetwork>(
2009    runner: MultiContractRunner<FEN>,
2010    filter: &ProjectPathsAwareFilter,
2011) -> Result<TestOutcome> {
2012    let results = runner.list(filter);
2013
2014    if shell::is_json() {
2015        sh_println!("{}", serde_json::to_string(&results)?)?;
2016    } else {
2017        for (file, contracts) in &results {
2018            sh_println!("{file}")?;
2019            for (contract, tests) in contracts {
2020                sh_println!("  {contract}")?;
2021                sh_println!("    {}\n", tests.join("\n    "))?;
2022            }
2023        }
2024    }
2025    Ok(TestOutcome::empty(Some(runner.known_contracts), false))
2026}
2027
2028/// Merges `other` into `base` by extending suite results.
2029///
2030/// For suites that appear in both, test results are combined (function-level pass routing ensures
2031/// each function appears in exactly one pass, so there are no key conflicts in practice).
2032fn merge_outcomes(base: &mut TestOutcome, other: TestOutcome) {
2033    for (suite_id, other_suite) in other.results {
2034        match base.results.entry(suite_id) {
2035            std::collections::btree_map::Entry::Vacant(e) => {
2036                e.insert(other_suite);
2037            }
2038            std::collections::btree_map::Entry::Occupied(mut e) => {
2039                let base_suite = e.get_mut();
2040                base_suite.test_results.extend(other_suite.test_results);
2041                base_suite.warnings.extend(other_suite.warnings);
2042                base_suite.duration = base_suite.duration.max(other_suite.duration);
2043            }
2044        }
2045    }
2046    if let Some(decoder) = other.last_run_decoder {
2047        base.last_run_decoder = Some(decoder);
2048    }
2049}
2050
2051struct LastRunFailures {
2052    test_pattern: Option<regex::Regex>,
2053    failures: Option<Vec<RerunFailure>>,
2054}
2055
2056/// Load persisted filter (with last test run failures) from file.
2057fn last_run_failures(config: &Config) -> LastRunFailures {
2058    let Ok(filter) = fs::read_to_string(&config.test_failures_file) else {
2059        return LastRunFailures { test_pattern: None, failures: None };
2060    };
2061
2062    if let Ok(failures) = serde_json::from_str::<RerunFailures>(&filter) {
2063        if failures.failures.is_empty() {
2064            return LastRunFailures { test_pattern: None, failures: None };
2065        }
2066        let test_pattern = failures
2067            .failures
2068            .iter()
2069            .map(|failure| regex::escape(&failure.test))
2070            .collect::<Vec<_>>()
2071            .join("|");
2072        let test_pattern = Regex::new(&test_pattern).ok();
2073        return LastRunFailures { test_pattern, failures: Some(failures.failures) };
2074    }
2075
2076    let test_pattern = Regex::new(&filter)
2077        .inspect_err(|e| {
2078            _ = sh_warn!("failed to parse test filter from {:?}: {e}", config.test_failures_file)
2079        })
2080        .ok();
2081    LastRunFailures { test_pattern, failures: None }
2082}
2083
2084/// Persist filter with last test run failures (only if there's any failure).
2085fn persist_run_failures(config: &Config, outcome: &TestOutcome) {
2086    if outcome.failed() > 0 && fs::create_file(&config.test_failures_file).is_ok() {
2087        let failures = outcome
2088            .results
2089            .iter()
2090            .flat_map(|(contract, suite)| {
2091                suite.test_results.iter().filter(|(_, result)| result.status.is_failure()).flat_map(
2092                    move |(test_name, test_result)| {
2093                        rerun_filter_matches(test_name, test_result)
2094                            .map(move |test| RerunFailure { contract: contract.clone(), test })
2095                    },
2096                )
2097            })
2098            .collect::<Vec<_>>();
2099
2100        let output = serde_json::to_string(&RerunFailures { version: 1, failures });
2101        if let Ok(output) = output {
2102            let _ = fs::write(&config.test_failures_file, output);
2103        }
2104    }
2105}
2106
2107fn rerun_filter_matches<'a>(
2108    test_name: &'a str,
2109    test_result: &'a TestResult,
2110) -> impl Iterator<Item = String> + 'a {
2111    let has_predicate_failures =
2112        test_result.invariant_failures.iter().any(|failure| failure.predicate_name().is_some());
2113    let predicate_failures =
2114        test_result.invariant_failures.iter().filter_map(|failure| failure.predicate_name());
2115
2116    let fallback = test_name.is_any_test().then(|| test_name.split('(').next()).flatten();
2117
2118    predicate_failures
2119        .chain(fallback.into_iter().filter(move |_| !has_predicate_failures))
2120        .map(str::to_owned)
2121}
2122
2123/// Generate test report in JUnit XML report format.
2124fn junit_xml_report(results: &BTreeMap<String, SuiteResult>, verbosity: u8) -> Report {
2125    let mut total_duration = Duration::default();
2126    let mut junit_report = Report::new("Test run");
2127    junit_report.set_timestamp(Utc::now());
2128    for (suite_name, suite_result) in results {
2129        let mut test_suite = TestSuite::new(suite_name);
2130        total_duration += suite_result.duration;
2131        test_suite.set_time(suite_result.duration);
2132        test_suite.set_system_out(suite_result.summary());
2133        for (test_name, test_result) in &suite_result.test_results {
2134            add_junit_test_cases(&mut test_suite, test_name, test_result, verbosity);
2135        }
2136        junit_report.add_test_suite(test_suite);
2137    }
2138    junit_report.set_time(total_duration);
2139    junit_report
2140}
2141
2142/// Adds JUnit test cases for a test result.
2143///
2144/// Invariant campaigns are expanded into per-predicate and per-handler cases so CI can report
2145/// contract-level execution without losing failure attribution.
2146fn add_junit_test_cases(
2147    test_suite: &mut TestSuite,
2148    test_name: &str,
2149    test_result: &TestResult,
2150    verbosity: u8,
2151) {
2152    let output = JunitOutput::new(test_result, verbosity);
2153    let expanded_invariant = test_result.kind.is_invariant()
2154        && (!test_result.invariant_predicate_results.is_empty()
2155            || !test_result.invariant_handler_failures.is_empty());
2156
2157    if !expanded_invariant {
2158        add_junit_test_case(
2159            test_suite,
2160            test_name,
2161            test_result.status,
2162            test_result.reason.as_deref(),
2163            test_result,
2164            output.system_out(test_result, test_name),
2165        );
2166        return;
2167    }
2168
2169    let mut add_expanded_case =
2170        |name: &str,
2171         status: TestStatus,
2172         reason: Option<&str>,
2173         counterexample: Option<&CounterExample>| {
2174            add_junit_test_case(
2175                test_suite,
2176                name,
2177                status,
2178                reason,
2179                test_result,
2180                output.case_system_out(status, reason, name, counterexample),
2181            );
2182        };
2183
2184    if test_result.invariant_predicate_results.is_empty() {
2185        let failure = test_result.invariant_failures.first();
2186        let status = if failure.is_some() { TestStatus::Failure } else { TestStatus::Success };
2187        add_expanded_case(
2188            test_name,
2189            status,
2190            failure.map(|failure| failure.reason()),
2191            failure.and_then(|failure| failure.counterexample()),
2192        );
2193    } else {
2194        for predicate in &test_result.invariant_predicate_results {
2195            let failure = test_result
2196                .invariant_failures
2197                .iter()
2198                .find(|failure| failure.name() == predicate.name.as_str());
2199            let name = format!("{}()", predicate.name);
2200            add_expanded_case(
2201                &name,
2202                predicate.status,
2203                predicate.reason.as_deref(),
2204                failure.and_then(|failure| failure.counterexample()),
2205            );
2206        }
2207    }
2208
2209    for failure in &test_result.invariant_handler_failures {
2210        let name = format!("handler {}", failure.name());
2211        add_expanded_case(
2212            &name,
2213            TestStatus::Failure,
2214            Some(failure.reason()),
2215            failure.counterexample(),
2216        );
2217    }
2218}
2219
2220/// Adds a single JUnit test case to the suite.
2221fn add_junit_test_case(
2222    test_suite: &mut TestSuite,
2223    test_name: &str,
2224    status: TestStatus,
2225    message: Option<&str>,
2226    test_result: &TestResult,
2227    system_out: String,
2228) {
2229    let mut test_status = match status {
2230        TestStatus::Success => TestCaseStatus::success(),
2231        TestStatus::Failure => TestCaseStatus::non_success(NonSuccessKind::Failure),
2232        TestStatus::Skipped => TestCaseStatus::skipped(),
2233    };
2234    if let Some(message) = message {
2235        test_status.set_message(message);
2236    }
2237
2238    let mut test_case = TestCase::new(test_name, test_status);
2239    test_case.set_time(test_result.duration);
2240    test_case.set_system_out(system_out);
2241    test_suite.add_test_case(test_case);
2242}
2243
2244/// Helper for assembling JUnit output strings.
2245struct JunitOutput {
2246    result_report: TestKindReport,
2247    logs: Option<Vec<String>>,
2248}
2249
2250impl JunitOutput {
2251    /// Creates a JUnit output helper for a test result.
2252    fn new(test_result: &TestResult, verbosity: u8) -> Self {
2253        Self {
2254            result_report: test_result.kind.report(),
2255            logs: (verbosity >= 2 && !test_result.logs.is_empty())
2256                .then(|| decode_console_logs(&test_result.logs)),
2257        }
2258    }
2259
2260    /// Renders the suite-level `system-out` payload.
2261    fn system_out(&self, test_result: &TestResult, test_name: &str) -> String {
2262        let mut sys_out = String::new();
2263        write!(sys_out, "{test_result} {test_name} {}", self.result_report).unwrap();
2264        self.append_logs(&mut sys_out);
2265        sys_out
2266    }
2267
2268    /// Renders the case-level `system-out` payload.
2269    fn case_system_out(
2270        &self,
2271        status: TestStatus,
2272        message: Option<&str>,
2273        test_name: &str,
2274        counterexample: Option<&CounterExample>,
2275    ) -> String {
2276        let mut sys_out = String::new();
2277        match status {
2278            TestStatus::Success => write!(sys_out, "[PASS]").unwrap(),
2279            TestStatus::Failure => {
2280                let message = message.unwrap_or_default();
2281                write!(sys_out, "[FAIL: {message}]").unwrap();
2282            }
2283            TestStatus::Skipped => {
2284                if let Some(message) = message {
2285                    write!(sys_out, "[SKIP: {message}]").unwrap();
2286                } else {
2287                    write!(sys_out, "[SKIP]").unwrap();
2288                }
2289            }
2290        }
2291        write!(sys_out, " {test_name} {}", self.result_report).unwrap();
2292        if let Some(CounterExample::Sequence(original, sequence)) = counterexample {
2293            writeln!(sys_out, "\n\t[Sequence] (original: {original}, shrunk: {})", sequence.len())
2294                .unwrap();
2295            for ex in sequence {
2296                writeln!(sys_out, "{ex}").unwrap();
2297            }
2298        }
2299        self.append_logs(&mut sys_out);
2300        sys_out
2301    }
2302
2303    /// Appends captured console logs to the output payload.
2304    fn append_logs(&self, sys_out: &mut String) {
2305        if let Some(logs) = &self.logs {
2306            write!(sys_out, "\\nLogs:\\n").unwrap();
2307            for log in logs {
2308                write!(sys_out, "  {log}\\n").unwrap();
2309            }
2310        }
2311    }
2312}
2313
2314#[cfg(test)]
2315mod tests {
2316    use super::*;
2317    use foundry_config::Chain;
2318
2319    #[test]
2320    fn watch_parse() {
2321        let args: TestArgs = TestArgs::parse_from(["foundry-cli", "-vw"]);
2322        assert!(args.watch.watch.is_some());
2323    }
2324
2325    #[test]
2326    fn fuzz_seed() {
2327        let args: TestArgs = TestArgs::parse_from(["foundry-cli", "--fuzz-seed", "0x10"]);
2328        assert!(args.fuzz_seed.is_some());
2329    }
2330
2331    #[test]
2332    fn depth_trace() {
2333        let args: TestArgs = TestArgs::parse_from(["foundry-cli", "--trace-depth", "2"]);
2334        assert!(args.trace_depth.is_some());
2335    }
2336
2337    // <https://github.com/foundry-rs/foundry/issues/5913>
2338    #[test]
2339    fn fuzz_seed_exists() {
2340        let args: TestArgs =
2341            TestArgs::parse_from(["foundry-cli", "-vvv", "--gas-report", "--fuzz-seed", "0x10"]);
2342        assert!(args.fuzz_seed.is_some());
2343    }
2344
2345    #[test]
2346    fn fuzz_run() {
2347        let args: TestArgs =
2348            TestArgs::parse_from(["foundry-cli", "--fuzz-run", "10", "--fuzz-worker", "2"]);
2349        assert_eq!(args.fuzz_run, Some(10));
2350        assert_eq!(args.fuzz_worker, Some(2));
2351    }
2352
2353    #[test]
2354    fn invariant_workers() {
2355        let args = TestArgs::parse_from(["foundry-cli", "--invariant-workers", "4"]);
2356        assert_eq!(
2357            args.invariant_workers,
2358            Some(InvariantWorkers::Fixed(std::num::NonZeroUsize::new(4).unwrap()))
2359        );
2360
2361        let figment = figment::Figment::from(&args);
2362        assert_eq!(
2363            figment.extract_inner::<InvariantWorkers>("invariant.workers").unwrap(),
2364            InvariantWorkers::Fixed(std::num::NonZeroUsize::new(4).unwrap())
2365        );
2366    }
2367
2368    #[test]
2369    fn invariant_workers_accepts_auto() {
2370        let args = TestArgs::parse_from(["foundry-cli", "--invariant-workers", "auto"]);
2371        assert_eq!(args.invariant_workers, Some(InvariantWorkers::Auto));
2372
2373        let figment = figment::Figment::from(&args);
2374        assert_eq!(
2375            figment.extract_inner::<InvariantWorkers>("invariant.workers").unwrap(),
2376            InvariantWorkers::Auto
2377        );
2378    }
2379
2380    #[test]
2381    fn invariant_workers_env_accepts_auto() {
2382        static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2383
2384        let _guard = ENV_LOCK.lock().unwrap();
2385        let previous = std::env::var_os("FOUNDRY_INVARIANT_WORKERS");
2386        unsafe {
2387            std::env::set_var("FOUNDRY_INVARIANT_WORKERS", "auto");
2388        }
2389
2390        let args = TestArgs::try_parse_from(["foundry-cli"]);
2391
2392        unsafe {
2393            if let Some(previous) = previous {
2394                std::env::set_var("FOUNDRY_INVARIANT_WORKERS", previous);
2395            } else {
2396                std::env::remove_var("FOUNDRY_INVARIANT_WORKERS");
2397            }
2398        }
2399
2400        assert_eq!(args.unwrap().invariant_workers, Some(InvariantWorkers::Auto));
2401    }
2402
2403    #[test]
2404    fn extract_chain() {
2405        let test = |arg: &str, expected: Chain| {
2406            let args = TestArgs::parse_from(["foundry-cli", arg]);
2407            assert_eq!(args.evm.env.chain, Some(expected));
2408            let (config, evm_opts) = args.load_config_and_evm_opts().unwrap();
2409            assert_eq!(config.chain, Some(expected));
2410            assert_eq!(evm_opts.env.chain_id, Some(expected.id()));
2411        };
2412        test("--chain-id=1", Chain::mainnet());
2413        test("--chain-id=42", Chain::from_id(42));
2414    }
2415}