Skip to main content

forge/cmd/test/
mod.rs

1use super::{install, test::filter::ProjectPathsAwareFilter, watch::WatchArgs};
2use crate::{
3    MultiContractRunner, MultiContractRunnerBuilder,
4    decode::decode_console_logs,
5    gas_report::GasReport,
6    multi_runner::{MultiNetworkConfig, matches_artifact},
7    result::{SuiteResult, TestOutcome, TestStatus},
8    traces::{
9        CallTraceDecoderBuilder, InternalTraceMode, TraceKind,
10        debug::{ContractSources, DebugTraceIdentifier},
11        decode_trace_arena, folded_stack_trace,
12        identifier::SignaturesIdentifier,
13    },
14};
15use alloy_primitives::U256;
16use chrono::Utc;
17use clap::{Parser, ValueHint};
18use eyre::{Context, OptionExt, Result, bail};
19use foundry_cli::{
20    opts::{BuildOpts, EvmArgs, GlobalArgs},
21    utils::{self, LoadConfig},
22};
23use foundry_common::{EmptyTestFilter, TestFunctionExt, compile::ProjectCompiler, fs, shell};
24use foundry_compilers::{
25    ProjectCompileOutput,
26    artifacts::{Libraries, output_selection::OutputSelection},
27    compilers::{
28        Language,
29        multi::{MultiCompiler, MultiCompilerLanguage},
30    },
31    utils::source_files_iter,
32};
33use foundry_config::{
34    Config, InlineConfig, figment,
35    figment::{
36        Metadata, Profile, Provider,
37        value::{Dict, Map},
38    },
39    filter::GlobMatcher,
40};
41use foundry_debugger::Debugger;
42#[cfg(feature = "optimism")]
43use foundry_evm::core::evm::OpEvmNetwork;
44use foundry_evm::{
45    core::evm::{
46        BlockEnvFor, EthEvmNetwork, FoundryEvmNetwork, SpecFor, TempoEvmNetwork, TxEnvFor,
47    },
48    opts::EvmOpts,
49    traces::{backtrace::BacktraceBuilder, identifier::TraceIdentifiers, prune_trace_depth},
50};
51use rand::Rng;
52use regex::Regex;
53use revm::context::Transaction;
54use std::{
55    collections::{BTreeMap, BTreeSet},
56    fmt::Write,
57    path::{Path, PathBuf},
58    sync::{Arc, mpsc::channel},
59    time::{Duration, Instant},
60};
61use yansi::Paint;
62
63mod filter;
64mod summary;
65use crate::{result::TestKind, traces::render_trace_arena_inner};
66pub use filter::FilterArgs;
67use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite};
68use summary::{TestSummaryReport, format_invariant_metrics_table};
69
70// Loads project's figment and merges the build cli arguments into it
71foundry_config::merge_impl_figment_convert!(TestArgs, build, evm);
72
73/// CLI arguments for `forge test`.
74#[derive(Clone, Debug, Parser)]
75#[command(next_help_heading = "Test options")]
76pub struct TestArgs {
77    // Include global options for users of this struct.
78    #[command(flatten)]
79    pub global: GlobalArgs,
80
81    /// The contract file you want to test, it's a shortcut for --match-path.
82    #[arg(value_hint = ValueHint::FilePath)]
83    pub path: Option<GlobMatcher>,
84
85    /// Run a single test in the debugger.
86    ///
87    /// The matching test will be opened in the debugger regardless of the outcome of the test.
88    ///
89    /// If the matching test is a fuzz test, then it will open the debugger on the first failure
90    /// case. If the fuzz test does not fail, it will open the debugger on the last fuzz case.
91    #[arg(long, conflicts_with_all = ["flamegraph", "flamechart", "decode_internal", "rerun"])]
92    debug: bool,
93
94    /// Generate a flamegraph for a single test. Implies `--decode-internal`.
95    ///
96    /// A flame graph is used to visualize which functions or operations within the smart contract
97    /// are consuming the most gas overall in a sorted manner.
98    #[arg(long)]
99    flamegraph: bool,
100
101    /// Generate a flamechart for a single test. Implies `--decode-internal`.
102    ///
103    /// A flame chart shows the gas usage over time, illustrating when each function is
104    /// called (execution order) and how much gas it consumes at each point in the timeline.
105    #[arg(long, conflicts_with = "flamegraph")]
106    flamechart: bool,
107
108    /// Identify internal functions in traces.
109    ///
110    /// This will trace internal functions and decode stack parameters.
111    ///
112    /// Parameters stored in memory (such as bytes or arrays) are currently decoded only when a
113    /// single function is matched, similarly to `--debug`, for performance reasons.
114    #[arg(long)]
115    decode_internal: bool,
116
117    /// Dumps all debugger steps to file.
118    #[arg(
119        long,
120        requires = "debug",
121        value_hint = ValueHint::FilePath,
122        value_name = "PATH"
123    )]
124    dump: Option<PathBuf>,
125
126    /// Print a gas report.
127    #[arg(long, env = "FORGE_GAS_REPORT")]
128    gas_report: bool,
129
130    /// Check gas snapshots against previous runs.
131    #[arg(long, env = "FORGE_SNAPSHOT_CHECK")]
132    gas_snapshot_check: Option<bool>,
133
134    /// Enable/disable recording of gas snapshot results.
135    #[arg(long, env = "FORGE_SNAPSHOT_EMIT")]
136    gas_snapshot_emit: Option<bool>,
137
138    /// Exit with code 0 even if a test fails.
139    #[arg(long, env = "FORGE_ALLOW_FAILURE")]
140    allow_failure: bool,
141
142    /// Suppress successful test traces and show only traces for failures.
143    #[arg(long, short, env = "FORGE_SUPPRESS_SUCCESSFUL_TRACES", help_heading = "Display options")]
144    suppress_successful_traces: bool,
145
146    /// Defines the depth of a trace
147    #[arg(long)]
148    trace_depth: Option<usize>,
149
150    /// Output test results as JUnit XML report.
151    #[arg(long, conflicts_with_all = ["quiet", "json", "gas_report", "summary", "list", "show_progress"], help_heading = "Display options")]
152    pub junit: bool,
153
154    /// Stop running tests after the first failure.
155    #[arg(long)]
156    pub fail_fast: bool,
157
158    /// The Etherscan (or equivalent) API key.
159    #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")]
160    etherscan_api_key: Option<String>,
161
162    /// List tests instead of running them.
163    #[arg(long, short, conflicts_with_all = ["show_progress", "decode_internal", "summary"], help_heading = "Display options")]
164    list: bool,
165
166    /// Set seed used to generate randomness during your fuzz runs.
167    #[arg(long)]
168    pub fuzz_seed: Option<U256>,
169
170    #[arg(long, env = "FOUNDRY_FUZZ_RUNS", value_name = "RUNS")]
171    pub fuzz_runs: Option<u64>,
172
173    /// Run only the fuzz case at the given 1-based run index.
174    #[arg(long, env = "FOUNDRY_FUZZ_RUN", value_name = "RUN")]
175    pub fuzz_run: Option<u32>,
176
177    /// Run the fuzz case from the given worker. Requires `--fuzz-run`.
178    #[arg(long, env = "FOUNDRY_FUZZ_WORKER", value_name = "WORKER", requires = "fuzz_run")]
179    pub fuzz_worker: Option<u32>,
180
181    /// Timeout for each fuzz run in seconds.
182    #[arg(long, env = "FOUNDRY_FUZZ_TIMEOUT", value_name = "TIMEOUT")]
183    pub fuzz_timeout: Option<u64>,
184
185    /// File to rerun fuzz failures from.
186    #[arg(long)]
187    pub fuzz_input_file: Option<String>,
188
189    /// Show test execution progress.
190    #[arg(long, conflicts_with_all = ["quiet", "json"], help_heading = "Display options")]
191    pub show_progress: bool,
192
193    /// Re-run recorded test failures from last run.
194    /// If no failure recorded then regular test run is performed.
195    #[arg(long)]
196    pub rerun: bool,
197
198    /// Print test summary table.
199    #[arg(long, help_heading = "Display options")]
200    pub summary: bool,
201
202    /// Print detailed test summary table.
203    #[arg(long, help_heading = "Display options", requires = "summary")]
204    pub detailed: bool,
205
206    /// Disables the labels in the traces.
207    #[arg(long, help_heading = "Display options")]
208    pub disable_labels: bool,
209
210    #[command(flatten)]
211    filter: FilterArgs,
212
213    #[command(flatten)]
214    evm: EvmArgs,
215
216    #[command(flatten)]
217    pub build: BuildOpts,
218
219    #[command(flatten)]
220    pub watch: WatchArgs,
221}
222
223impl TestArgs {
224    pub async fn run(mut self) -> Result<TestOutcome> {
225        trace!(target: "forge::test", "executing test command");
226        self.compile_and_run().await
227    }
228
229    /// Returns a list of files that need to be compiled in order to run all the tests that match
230    /// the given filter.
231    ///
232    /// This means that it will return all sources that are not test contracts or that match the
233    /// filter. We want to compile all non-test sources always because tests might depend on them
234    /// dynamically through cheatcodes.
235    #[instrument(target = "forge::test", skip_all)]
236    pub fn get_sources_to_compile(
237        &self,
238        config: &Config,
239        test_filter: &ProjectPathsAwareFilter,
240    ) -> Result<BTreeSet<PathBuf>> {
241        // An empty filter doesn't filter out anything.
242        // We can still optimize slightly by excluding scripts.
243        if test_filter.is_empty() {
244            return Ok(source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
245                .chain(source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS))
246                .collect());
247        }
248
249        let mut project = config.create_project(true, true)?;
250        project.update_output_selection(|selection| {
251            *selection = OutputSelection::common_output_selection(["abi".to_string()]);
252        });
253        let output = project.compile()?;
254        if output.has_compiler_errors() {
255            sh_println!("{output}")?;
256            eyre::bail!("Compilation failed");
257        }
258
259        Ok(output
260            .artifact_ids()
261            .filter_map(|(id, artifact)| artifact.abi.as_ref().map(|abi| (id, abi)))
262            .filter(|(id, abi)| {
263                id.source.starts_with(&config.src) || matches_artifact(test_filter, id, abi)
264            })
265            .map(|(id, _)| id.source)
266            .collect())
267    }
268
269    /// Executes all the tests in the project.
270    ///
271    /// This will trigger the build process first. On success all test contracts that match the
272    /// configured filter will be executed
273    ///
274    /// Returns the test results for all matching tests.
275    pub async fn compile_and_run(&mut self) -> Result<TestOutcome> {
276        // Merge all configs.
277        let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
278
279        // Install missing dependencies.
280        if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings
281        {
282            // need to re-configure here to also catch additional remappings
283            config = self.load_config()?;
284        }
285
286        // Set up the project.
287        let project = config.project()?;
288
289        let filter = self.filter(&config)?;
290        trace!(target: "forge::test", ?filter, "using filter");
291
292        let compiler = ProjectCompiler::new()
293            .dynamic_test_linking(config.dynamic_test_linking)
294            .quiet(shell::is_json() || self.junit)
295            .files(self.get_sources_to_compile(&config, &filter)?);
296        let output = compiler.compile(&project)?;
297
298        self.run_tests(&project.paths.root, config, evm_opts, &output, &filter, false).await
299    }
300
301    /// Executes all the tests in the project.
302    ///
303    /// See [`Self::compile_and_run`] for more details.
304    pub async fn run_tests(
305        &mut self,
306        project_root: &Path,
307        mut config: Config,
308        mut evm_opts: EvmOpts,
309        output: &ProjectCompileOutput,
310        filter: &ProjectPathsAwareFilter,
311        coverage: bool,
312    ) -> Result<TestOutcome> {
313        if config.fuzz.run == Some(0) {
314            bail!("`fuzz.run` must be greater than 0");
315        }
316
317        // Explicitly enable isolation for gas reports for more correct gas accounting.
318        if self.gas_report {
319            evm_opts.isolate = true;
320        } else {
321            // Do not collect gas report traces if gas report is not enabled.
322            config.fuzz.gas_report_samples = 0;
323            config.invariant.gas_report_samples = 0;
324        }
325
326        // Generate a random fuzz seed if none provided, for reproducibility.
327        config.fuzz.seed = config
328            .fuzz
329            .seed
330            .or_else(|| Some(U256::from_be_bytes(rand::rng().random::<[u8; 32]>())));
331
332        // Create test options from general project settings and compiler output.
333        let should_debug = self.debug;
334        let should_draw = self.flamegraph || self.flamechart;
335
336        // Determine executor verbosity.
337        if (self.gas_report && evm_opts.verbosity < 3) || self.flamegraph || self.flamechart {
338            evm_opts.verbosity = 3;
339        }
340
341        // Enable internal tracing for more informative flamegraph.
342        if should_draw && !self.decode_internal {
343            self.decode_internal = true;
344        }
345
346        // Choose the internal function tracing mode, if --decode-internal is provided.
347        let decode_internal = if self.decode_internal {
348            // If more than one function matched, we enable simple tracing.
349            // If only one function matched, we enable full tracing. This is done in `run_tests`.
350            InternalTraceMode::Simple
351        } else {
352            InternalTraceMode::None
353        };
354
355        // Auto-detect network from fork chain ID when not explicitly configured.
356        evm_opts.infer_network_from_fork().await;
357
358        // Parse inline config early to detect per-test network annotations.
359        let inline_config = InlineConfig::new_parsed(output, &config)?;
360        let override_networks = inline_config.referenced_override_networks(&config.profile);
361
362        let (libraries, mut outcome) = if override_networks.is_empty() {
363            // Single-pass: no per-test network overrides, use global network setting.
364            self.dispatch_network(
365                &evm_opts,
366                config,
367                evm_opts.clone(),
368                output,
369                filter,
370                coverage,
371                should_debug,
372                decode_internal,
373                MultiNetworkConfig::default(),
374            )
375            .await?
376        } else {
377            // Multi-pass: run each distinct network separately and merge results.
378            let all_override_networks = override_networks.clone();
379            let multi_pass_timer = Instant::now();
380
381            // Default pass: global network, runs tests without an explicit network annotation.
382            let (libraries, mut outcome) = self
383                .dispatch_network(
384                    &evm_opts,
385                    config.clone(),
386                    evm_opts.clone(),
387                    output,
388                    filter,
389                    coverage,
390                    should_debug,
391                    decode_internal,
392                    MultiNetworkConfig {
393                        all_override_networks: all_override_networks.clone(),
394                        pass_network: None,
395                    },
396                )
397                .await?;
398
399            // Override passes: one per annotated network.
400            for &network in &override_networks {
401                let mut pass_evm_opts = evm_opts.clone();
402                pass_evm_opts.networks = network.into();
403                let (_, pass_outcome) = self
404                    .dispatch_network(
405                        &pass_evm_opts,
406                        config.clone(),
407                        pass_evm_opts.clone(),
408                        output,
409                        filter,
410                        coverage,
411                        should_debug,
412                        decode_internal,
413                        MultiNetworkConfig {
414                            all_override_networks: all_override_networks.clone(),
415                            pass_network: Some(network),
416                        },
417                    )
418                    .await?;
419                merge_outcomes(&mut outcome, pass_outcome);
420            }
421
422            // Print the merged summary (per-pass summaries are suppressed in `run_tests_inner`).
423            if !self.summary && !shell::is_json() {
424                sh_println!("{}", outcome.summary(multi_pass_timer.elapsed()))?;
425            }
426            if self.summary && !outcome.results.is_empty() {
427                let summary_report = TestSummaryReport::new(self.detailed, outcome.clone());
428                sh_println!("{}", &summary_report)?;
429            }
430
431            (libraries, outcome)
432        };
433
434        if should_draw {
435            let (suite_name, test_name, mut test_result) =
436                outcome.remove_first().ok_or_eyre("no tests were executed")?;
437
438            let (_, arena) = test_result
439                .traces
440                .iter_mut()
441                .find(|(kind, _)| *kind == TraceKind::Execution)
442                .unwrap();
443
444            // Decode traces.
445            let decoder = outcome.last_run_decoder.as_ref().unwrap();
446            decode_trace_arena(arena, decoder).await;
447            let mut fst = folded_stack_trace::build(arena, self.evm.isolate);
448
449            let label = if self.flamegraph { "flamegraph" } else { "flamechart" };
450            let contract = suite_name.split(':').next_back().unwrap();
451            let test_name = test_name.trim_end_matches("()");
452            let file_name = format!("cache/{label}_{contract}_{test_name}.svg");
453            let file = std::fs::File::create(&file_name).wrap_err("failed to create file")?;
454            let file = std::io::BufWriter::new(file);
455
456            let mut options = inferno::flamegraph::Options::default();
457            options.title = format!("{label} {contract}::{test_name}");
458            options.count_name = "gas".to_string();
459            if self.flamechart {
460                options.flame_chart = true;
461                fst.reverse();
462            }
463
464            // Generate SVG.
465            inferno::flamegraph::from_lines(&mut options, fst.iter().map(String::as_str), file)
466                .wrap_err("failed to write svg")?;
467            sh_println!("Saved to {file_name}")?;
468
469            // Open SVG in default program.
470            if let Err(e) = opener::open(&file_name) {
471                sh_err!("Failed to open {file_name}; please open it manually: {e}")?;
472            }
473        }
474
475        if should_debug {
476            // Get first non-empty suite result. We will have only one such entry.
477            let (_, _, test_result) =
478                outcome.remove_first().ok_or_eyre("no tests were executed")?;
479
480            let sources =
481                ContractSources::from_project_output(output, project_root, Some(&libraries))?;
482
483            // Run the debugger.
484            let mut builder = Debugger::builder()
485                .traces(
486                    test_result.traces.iter().filter(|(t, _)| t.is_execution()).cloned().collect(),
487                )
488                .sources(sources)
489                .breakpoints(test_result.breakpoints.clone());
490
491            if let Some(decoder) = &outcome.last_run_decoder {
492                builder = builder.decoder(decoder);
493            }
494
495            let mut debugger = builder.build();
496            if let Some(dump_path) = &self.dump {
497                debugger.dump_to_file(dump_path)?;
498            } else {
499                debugger.try_run_tui()?;
500            }
501        }
502
503        Ok(outcome)
504    }
505
506    /// Build the test runner and execute tests for a specific network type.
507    #[allow(clippy::too_many_arguments)]
508    async fn build_and_run_tests<FEN: FoundryEvmNetwork>(
509        &self,
510        config: Config,
511        evm_opts: EvmOpts,
512        output: &ProjectCompileOutput,
513        filter: &ProjectPathsAwareFilter,
514        coverage: bool,
515        should_debug: bool,
516        decode_internal: InternalTraceMode,
517        multi_network: MultiNetworkConfig,
518    ) -> eyre::Result<(Libraries, TestOutcome)> {
519        let verbosity = evm_opts.verbosity;
520        let (evm_env, tx_env, fork_block) =
521            evm_opts.env::<SpecFor<FEN>, BlockEnvFor<FEN>, TxEnvFor<FEN>>().await?;
522
523        let config = Arc::new(config);
524        let runner = MultiContractRunnerBuilder::new(config.clone())
525            .set_debug(should_debug)
526            .set_decode_internal(decode_internal)
527            .initial_balance(evm_opts.initial_balance)
528            .sender(evm_opts.sender)
529            .with_fork(evm_opts.get_fork(&config, evm_env.cfg_env.chain_id, fork_block))
530            .enable_isolation(evm_opts.isolate)
531            .fail_fast(self.fail_fast)
532            .set_coverage(coverage)
533            .with_multi_network(multi_network)
534            .build::<FEN, MultiCompiler>(output, evm_env, tx_env, evm_opts)?;
535
536        let libraries = runner.libraries.clone();
537        let outcome = self.run_tests_inner(runner, config, verbosity, filter, output).await?;
538        Ok((libraries, outcome))
539    }
540
541    /// Dispatches `build_and_run_tests` to the correct network type based on `evm_opts.networks`.
542    #[allow(clippy::too_many_arguments)]
543    async fn dispatch_network(
544        &self,
545        dispatch_opts: &EvmOpts,
546        config: Config,
547        evm_opts: EvmOpts,
548        output: &ProjectCompileOutput,
549        filter: &ProjectPathsAwareFilter,
550        coverage: bool,
551        should_debug: bool,
552        decode_internal: InternalTraceMode,
553        multi_network: MultiNetworkConfig,
554    ) -> eyre::Result<(Libraries, TestOutcome)> {
555        if dispatch_opts.networks.is_tempo() {
556            self.build_and_run_tests::<TempoEvmNetwork>(
557                config,
558                evm_opts,
559                output,
560                filter,
561                coverage,
562                should_debug,
563                decode_internal,
564                multi_network,
565            )
566            .await
567        } else {
568            #[cfg(feature = "optimism")]
569            if dispatch_opts.networks.is_optimism() {
570                return self
571                    .build_and_run_tests::<OpEvmNetwork>(
572                        config,
573                        evm_opts,
574                        output,
575                        filter,
576                        coverage,
577                        should_debug,
578                        decode_internal,
579                        multi_network,
580                    )
581                    .await;
582            }
583            self.build_and_run_tests::<EthEvmNetwork>(
584                config,
585                evm_opts,
586                output,
587                filter,
588                coverage,
589                should_debug,
590                decode_internal,
591                multi_network,
592            )
593            .await
594        }
595    }
596
597    /// Run all tests that matches the filter predicate from a test runner
598    async fn run_tests_inner<FEN: FoundryEvmNetwork>(
599        &self,
600        mut runner: MultiContractRunner<FEN>,
601        config: Arc<Config>,
602        verbosity: u8,
603        filter: &ProjectPathsAwareFilter,
604        output: &ProjectCompileOutput,
605    ) -> eyre::Result<TestOutcome> {
606        let fuzz_seed = config.fuzz.seed;
607        if self.list {
608            return list(runner, filter);
609        }
610
611        trace!(target: "forge::test", "running all tests");
612
613        // If we need to render to a serialized format, we should not print anything else to stdout.
614        let silent = self.gas_report && shell::is_json() || self.summary && shell::is_json();
615
616        let num_filtered = runner.matching_test_functions(filter).count();
617
618        if num_filtered == 0 {
619            let total_tests = if filter.is_empty() {
620                num_filtered
621            } else {
622                runner.matching_test_functions(&EmptyTestFilter::default()).count()
623            };
624            if total_tests == 0 {
625                sh_println!(
626                    "No tests found in project! Forge looks for functions that start with `test`"
627                )?;
628            } else {
629                let mut msg = format!("no tests match the provided pattern:\n{filter}");
630                // Try to suggest a test when there's no match.
631                if let Some(test_pattern) = &filter.args().test_pattern {
632                    let test_name = test_pattern.as_str();
633                    // Filter contracts but not test functions.
634                    let candidates = runner.all_test_functions(filter).map(|f| &f.name);
635                    if let Some(suggestion) = utils::did_you_mean(test_name, candidates).pop() {
636                        write!(msg, "\nDid you mean `{suggestion}`?")?;
637                    }
638                }
639                sh_warn!("{msg}")?;
640            }
641            return Ok(TestOutcome::empty(Some(runner.known_contracts.clone()), false));
642        }
643
644        if num_filtered != 1 && (self.debug || self.flamegraph || self.flamechart) {
645            let action = if self.flamegraph {
646                "generate a flamegraph"
647            } else if self.flamechart {
648                "generate a flamechart"
649            } else {
650                "run the debugger"
651            };
652            let filter = if filter.is_empty() {
653                String::new()
654            } else {
655                format!("\n\nFilter used:\n{filter}")
656            };
657            eyre::bail!(
658                "{num_filtered} tests matched your criteria, but exactly 1 test must match in order to {action}.\n\n\
659                 Use --match-contract and --match-path to further limit the search.{filter}",
660            );
661        }
662
663        // If exactly one test matched, we enable full tracing.
664        if num_filtered == 1 && self.decode_internal {
665            runner.decode_internal = InternalTraceMode::Full;
666        }
667
668        // Run tests in a non-streaming fashion and collect results for serialization.
669        if !self.gas_report && !self.summary && shell::is_json() {
670            let mut results = runner.test_collect(filter)?;
671            for suite_result in results.values_mut() {
672                for test_result in suite_result.test_results.values_mut() {
673                    if verbosity >= 2 {
674                        // Decode logs at level 2 and above.
675                        test_result.decoded_logs = decode_console_logs(&test_result.logs);
676                    } else {
677                        // Empty logs for non verbose runs.
678                        test_result.logs = vec![];
679                    }
680                }
681            }
682            sh_println!("{}", serde_json::to_string(&results)?)?;
683            let kc = runner.known_contracts.clone();
684            return Ok(TestOutcome::new(Some(kc), results, self.allow_failure, fuzz_seed));
685        }
686
687        if self.junit {
688            let results = runner.test_collect(filter)?;
689            sh_println!("{}", junit_xml_report(&results, verbosity).to_string()?)?;
690            let kc = runner.known_contracts.clone();
691            return Ok(TestOutcome::new(Some(kc), results, self.allow_failure, fuzz_seed));
692        }
693
694        let remote_chain =
695            if runner.fork.is_some() { runner.tx_env.chain_id().map(Into::into) } else { None };
696        let known_contracts = runner.known_contracts.clone();
697
698        let libraries = runner.libraries.clone();
699
700        // Capture multi-pass state before moving `runner` into the spawn task.
701        // In multi-pass mode the per-pass summary is suppressed; the merged summary is
702        // printed once by the caller after all passes complete.
703        let is_multi_pass = !runner.tcfg.multi_network.all_override_networks.is_empty();
704
705        // Run tests in a streaming fashion.
706        let (tx, rx) = channel::<(String, SuiteResult)>();
707        let timer = Instant::now();
708        let show_progress = config.show_progress;
709        let handle = tokio::task::spawn_blocking({
710            let filter = filter.clone();
711            move || runner.test(&filter, tx, show_progress).map(|()| runner)
712        });
713
714        // Set up trace identifiers.
715        let mut identifier = TraceIdentifiers::new().with_local(&known_contracts);
716
717        // Avoid using external identifiers for gas report as we decode more traces and this will be
718        // expensive. Also skip external identifiers for local tests (no remote chain) to avoid
719        // unnecessary Etherscan API calls that significantly slow down test execution.
720        if !self.gas_report && remote_chain.is_some() {
721            identifier = identifier.with_external(&config, remote_chain)?;
722        }
723
724        // Build the trace decoder.
725        let mut builder = CallTraceDecoderBuilder::new()
726            .with_known_contracts(&known_contracts)
727            .with_label_disabled(self.disable_labels)
728            .with_verbosity(verbosity)
729            .with_chain_id(remote_chain.map(|c| c.id()));
730        // Signatures are of no value for gas reports.
731        if !self.gas_report {
732            builder =
733                builder.with_signature_identifier(SignaturesIdentifier::from_config(&config)?);
734        }
735
736        if self.decode_internal {
737            let sources =
738                ContractSources::from_project_output(output, &config.root, Some(&libraries))?;
739            builder = builder.with_debug_identifier(DebugTraceIdentifier::new(sources));
740        }
741        let mut decoder = builder.build();
742
743        let mut gas_report = self.gas_report.then(|| {
744            GasReport::new(
745                config.gas_reports.clone(),
746                config.gas_reports_ignore.clone(),
747                config.gas_reports_include_tests,
748            )
749        });
750
751        let mut gas_snapshots = BTreeMap::<String, BTreeMap<String, String>>::new();
752
753        let mut outcome = TestOutcome::empty(None, self.allow_failure);
754        outcome.fuzz_seed = fuzz_seed;
755
756        let mut any_test_failed = false;
757        let mut backtrace_builder = None;
758        for (contract_name, mut suite_result) in rx {
759            let tests = &mut suite_result.test_results;
760            let has_tests = !tests.is_empty();
761
762            // In multi-pass (per-test network override) mode, skip suites that contributed no
763            // tests to this pass so we don't emit a stray blank line in the suite header or
764            // pollute the outcome with empty entries.
765            if is_multi_pass && !has_tests && suite_result.warnings.is_empty() {
766                continue;
767            }
768
769            // Clear the addresses and labels from previous test.
770            decoder.clear_addresses();
771
772            // We identify addresses if we're going to print *any* trace or gas report.
773            let identify_addresses = verbosity >= 3
774                || self.gas_report
775                || self.debug
776                || self.flamegraph
777                || self.flamechart;
778
779            // Print suite header.
780            if !silent {
781                sh_println!()?;
782                for warning in &suite_result.warnings {
783                    sh_warn!("{warning}")?;
784                }
785                if has_tests {
786                    let len = tests.len();
787                    let tests = if len > 1 { "tests" } else { "test" };
788                    sh_println!("Ran {len} {tests} for {contract_name}")?;
789                }
790            }
791
792            // Process individual test results, printing logs and traces when necessary.
793            for (name, result) in tests {
794                let show_traces =
795                    !self.suppress_successful_traces || result.status == TestStatus::Failure;
796                if !silent {
797                    sh_println!("{}", result.short_result(name))?;
798
799                    // Display invariant metrics if invariant kind.
800                    if let TestKind::Invariant { metrics, .. } = &result.kind
801                        && !metrics.is_empty()
802                    {
803                        let _ = sh_println!("\n{}\n", format_invariant_metrics_table(metrics));
804                    }
805
806                    // We only display logs at level 2 and above
807                    if verbosity >= 2 && show_traces {
808                        // We only decode logs from Hardhat and DS-style console events
809                        let console_logs = decode_console_logs(&result.logs);
810                        if !console_logs.is_empty() {
811                            sh_println!("Logs:")?;
812                            for log in console_logs {
813                                sh_println!("  {log}")?;
814                            }
815                            sh_println!()?;
816                        }
817                    }
818                }
819
820                // We shouldn't break out of the outer loop directly here so that we finish
821                // processing the remaining tests and print the suite summary.
822                any_test_failed |= result.status == TestStatus::Failure;
823
824                // Clear the addresses and labels from previous runs.
825                decoder.clear_addresses();
826                decoder.labels.extend(result.labels.iter().map(|(k, v)| (*k, v.clone())));
827
828                // Identify addresses and decode traces.
829                let mut decoded_traces = Vec::with_capacity(result.traces.len());
830                for (kind, arena) in &mut result.traces {
831                    if identify_addresses {
832                        decoder.identify(arena, &mut identifier);
833                    }
834
835                    // verbosity:
836                    // - 0..3: nothing
837                    // - 3: only display traces for failed tests
838                    // - 4: also display the setup trace for failed tests
839                    // - 5..: display all traces for all tests, including storage changes
840                    let should_include = match kind {
841                        TraceKind::Execution => {
842                            (verbosity == 3 && result.status.is_failure()) || verbosity >= 4
843                        }
844                        TraceKind::Setup => {
845                            (verbosity == 4 && result.status.is_failure()) || verbosity >= 5
846                        }
847                        TraceKind::Deployment => false,
848                    };
849
850                    if should_include {
851                        decode_trace_arena(arena, &decoder).await;
852
853                        if let Some(trace_depth) = self.trace_depth {
854                            prune_trace_depth(arena, trace_depth);
855                        }
856
857                        decoded_traces.push(render_trace_arena_inner(arena, false, verbosity > 4));
858                    }
859                }
860
861                if !silent && show_traces && !decoded_traces.is_empty() {
862                    sh_println!("Traces:")?;
863                    for trace in &decoded_traces {
864                        sh_println!("{trace}")?;
865                    }
866                }
867
868                // Extract and display backtrace for failed tests when verbosity >= 3.
869                // At verbosity 3-4 backtraces show contract/function names only.
870                // At verbosity 5 backtraces include source file locations.
871                if !silent
872                    && result.status.is_failure()
873                    && verbosity >= 3
874                    && !result.traces.is_empty()
875                    && let Some((_, arena)) =
876                        result.traces.iter().find(|(kind, _)| matches!(kind, TraceKind::Execution))
877                {
878                    // Lazily initialize the backtrace builder on first failure
879                    let builder = backtrace_builder.get_or_insert_with(|| {
880                        BacktraceBuilder::new(
881                            output,
882                            config.root.clone(),
883                            config.parsed_libraries().ok(),
884                            config.via_ir,
885                        )
886                    });
887
888                    let backtrace = builder.from_traces(arena);
889
890                    if !backtrace.is_empty() {
891                        sh_println!("{}", backtrace)?;
892                    }
893                }
894
895                if let Some(gas_report) = &mut gas_report {
896                    gas_report.analyze(result.traces.iter().map(|(_, a)| &a.arena), &decoder).await;
897
898                    for trace in &result.gas_report_traces {
899                        decoder.clear_addresses();
900
901                        // Re-execute setup and deployment traces to collect identities created in
902                        // setUp and constructor.
903                        for (kind, arena) in &result.traces {
904                            if !matches!(kind, TraceKind::Execution) {
905                                decoder.identify(arena, &mut identifier);
906                            }
907                        }
908
909                        for arena in trace {
910                            decoder.identify(arena, &mut identifier);
911                            gas_report.analyze([arena], &decoder).await;
912                        }
913                    }
914                }
915                // Clear memory.
916                result.gas_report_traces = Default::default();
917
918                // Collect and merge gas snapshots.
919                for (group, new_snapshots) in &result.gas_snapshots {
920                    gas_snapshots.entry(group.clone()).or_default().extend(new_snapshots.clone());
921                }
922            }
923
924            // Write gas snapshots to disk if any were collected.
925            if !gas_snapshots.is_empty() {
926                // By default `gas_snapshot_check` is set to `false` in the config.
927                //
928                // The user can either:
929                // - Set `FORGE_SNAPSHOT_CHECK=true` in the environment.
930                // - Pass `--gas-snapshot-check=true` as a CLI argument.
931                // - Set `gas_snapshot_check = true` in the config.
932                //
933                // If the user passes `--gas-snapshot-check=<bool>` then it will override the config
934                // and the environment variable, disabling the check if `false` is passed.
935                //
936                // Exiting early with code 1 if differences are found.
937                if self.gas_snapshot_check.unwrap_or(config.gas_snapshot_check) {
938                    let differences_found =
939                        gas_snapshots.iter().fold(false, |mut found, (group, snapshots)| {
940                            // If the snapshot file doesn't exist, we can't compare so we skip.
941                            if !&config.snapshots.join(format!("{group}.json")).exists() {
942                                return found;
943                            }
944
945                            let previous_snapshots: BTreeMap<String, String> =
946                                fs::read_json_file(&config.snapshots.join(format!("{group}.json")))
947                                    .expect("Failed to read snapshots from disk");
948
949                            let diff: BTreeMap<_, _> = snapshots
950                                .iter()
951                                .filter_map(|(k, v)| {
952                                    previous_snapshots.get(k).and_then(|previous_snapshot| {
953                                        (previous_snapshot != v).then(|| {
954                                            (k.clone(), (previous_snapshot.clone(), v.clone()))
955                                        })
956                                    })
957                                })
958                                .collect();
959
960                            if !diff.is_empty() {
961                                let _ = sh_eprintln!(
962                                    "{}",
963                                    format!("\n[{group}] Failed to match snapshots:").red().bold()
964                                );
965
966                                for (key, (previous_snapshot, snapshot)) in &diff {
967                                    let _ = sh_eprintln!(
968                                        "{}",
969                                        format!("- [{key}] {previous_snapshot} → {snapshot}").red()
970                                    );
971                                }
972
973                                found = true;
974                            }
975
976                            found
977                        });
978
979                    if differences_found {
980                        sh_eprintln!()?;
981                        eyre::bail!("Snapshots differ from previous run");
982                    }
983                }
984
985                // By default `gas_snapshot_emit` is set to `true` in the config.
986                //
987                // The user can either:
988                // - Set `FORGE_SNAPSHOT_EMIT=false` in the environment.
989                // - Pass `--gas-snapshot-emit=false` as a CLI argument.
990                // - Set `gas_snapshot_emit = false` in the config.
991                //
992                // If the user passes `--gas-snapshot-emit=<bool>` then it will override the config
993                // and the environment variable, enabling the check if `true` is passed.
994                if self.gas_snapshot_emit.unwrap_or(config.gas_snapshot_emit) {
995                    // Create `snapshots` directory if it doesn't exist.
996                    fs::create_dir_all(&config.snapshots)?;
997
998                    // Write gas snapshots to disk per group.
999                    for (group, snapshots) in &gas_snapshots {
1000                        fs::write_pretty_json_file(
1001                            &config.snapshots.join(format!("{group}.json")),
1002                            &snapshots,
1003                        )
1004                        .expect("Failed to write gas snapshots to disk");
1005                    }
1006                }
1007            }
1008
1009            // Print suite summary.
1010            if !silent && has_tests {
1011                sh_println!("{}", suite_result.summary())?;
1012            }
1013
1014            // Add the suite result to the outcome.
1015            outcome.results.insert(contract_name, suite_result);
1016
1017            // Stop processing the remaining suites if any test failed and `fail_fast` is set.
1018            if self.fail_fast && any_test_failed {
1019                break;
1020            }
1021        }
1022        outcome.last_run_decoder = Some(decoder);
1023        let duration = timer.elapsed();
1024
1025        trace!(target: "forge::test", len=outcome.results.len(), %any_test_failed, "done with results");
1026
1027        if let Some(gas_report) = gas_report {
1028            let finalized = gas_report.finalize();
1029            sh_println!("{finalized}")?;
1030            outcome.gas_report = Some(finalized);
1031        }
1032
1033        if !is_multi_pass && !self.summary && !shell::is_json() {
1034            sh_println!("{}", outcome.summary(duration))?;
1035        }
1036
1037        if !is_multi_pass && self.summary && !outcome.results.is_empty() {
1038            let summary_report = TestSummaryReport::new(self.detailed, outcome.clone());
1039            sh_println!("{summary_report}")?;
1040        }
1041
1042        // Reattach the task.
1043        match handle.await {
1044            Ok(result) => {
1045                let runner = result?;
1046                outcome.known_contracts = Some(runner.known_contracts);
1047            }
1048            Err(e) => match e.try_into_panic() {
1049                Ok(payload) => std::panic::resume_unwind(payload),
1050                Err(e) => return Err(e.into()),
1051            },
1052        }
1053
1054        // Persist test run failures to enable replaying.
1055        persist_run_failures(&config, &outcome);
1056
1057        Ok(outcome)
1058    }
1059
1060    /// Returns the flattened [`FilterArgs`] arguments merged with [`Config`].
1061    /// Loads and applies filter from file if only last test run failures performed.
1062    pub fn filter(&self, config: &Config) -> Result<ProjectPathsAwareFilter> {
1063        let mut filter = self.filter.clone();
1064        if self.rerun {
1065            filter.test_pattern = last_run_failures(config);
1066        }
1067        if filter.path_pattern.is_some() {
1068            if self.path.is_some() {
1069                bail!("Can not supply both --match-path and |path|");
1070            }
1071        } else {
1072            filter.path_pattern = self.path.clone();
1073        }
1074        Ok(filter.merge_with_config(config))
1075    }
1076
1077    /// Returns whether `BuildArgs` was configured with `--watch`
1078    pub const fn is_watch(&self) -> bool {
1079        self.watch.watch.is_some()
1080    }
1081
1082    /// Returns the [`watchexec::Config`] necessary to bootstrap a new watch loop.
1083    pub(crate) fn watchexec_config(&self) -> Result<watchexec::Config> {
1084        self.watch.watchexec_config(|| {
1085            let config = self.load_config()?;
1086            Ok([config.src, config.test])
1087        })
1088    }
1089}
1090
1091impl Provider for TestArgs {
1092    fn metadata(&self) -> Metadata {
1093        Metadata::named("Core Build Args Provider")
1094    }
1095
1096    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
1097        let mut dict = Dict::default();
1098
1099        let mut fuzz_dict = Dict::default();
1100        if let Some(fuzz_seed) = self.fuzz_seed {
1101            fuzz_dict.insert("seed".to_string(), fuzz_seed.to_string().into());
1102        }
1103        if let Some(fuzz_runs) = self.fuzz_runs {
1104            fuzz_dict.insert("runs".to_string(), fuzz_runs.into());
1105        }
1106        if let Some(fuzz_run) = self.fuzz_run {
1107            fuzz_dict.insert("run".to_string(), fuzz_run.into());
1108        }
1109        if let Some(fuzz_worker) = self.fuzz_worker {
1110            fuzz_dict.insert("worker".to_string(), fuzz_worker.into());
1111        }
1112        if let Some(fuzz_timeout) = self.fuzz_timeout {
1113            fuzz_dict.insert("timeout".to_string(), fuzz_timeout.into());
1114        }
1115        if let Some(fuzz_input_file) = self.fuzz_input_file.clone() {
1116            fuzz_dict.insert("failure_persist_file".to_string(), fuzz_input_file.into());
1117        }
1118        dict.insert("fuzz".to_string(), fuzz_dict.into());
1119
1120        if let Some(etherscan_api_key) =
1121            self.etherscan_api_key.as_ref().filter(|s| !s.trim().is_empty())
1122        {
1123            dict.insert("etherscan_api_key".to_string(), etherscan_api_key.clone().into());
1124        }
1125
1126        if self.show_progress {
1127            dict.insert("show_progress".to_string(), true.into());
1128        }
1129
1130        Ok(Map::from([(Config::selected_profile(), dict)]))
1131    }
1132}
1133
1134/// Lists all matching tests
1135fn list<FEN: FoundryEvmNetwork>(
1136    runner: MultiContractRunner<FEN>,
1137    filter: &ProjectPathsAwareFilter,
1138) -> Result<TestOutcome> {
1139    let results = runner.list(filter);
1140
1141    if shell::is_json() {
1142        sh_println!("{}", serde_json::to_string(&results)?)?;
1143    } else {
1144        for (file, contracts) in &results {
1145            sh_println!("{file}")?;
1146            for (contract, tests) in contracts {
1147                sh_println!("  {contract}")?;
1148                sh_println!("    {}\n", tests.join("\n    "))?;
1149            }
1150        }
1151    }
1152    Ok(TestOutcome::empty(Some(runner.known_contracts), false))
1153}
1154
1155/// Merges `other` into `base` by extending suite results.
1156///
1157/// For suites that appear in both, test results are combined (function-level pass routing ensures
1158/// each function appears in exactly one pass, so there are no key conflicts in practice).
1159fn merge_outcomes(base: &mut TestOutcome, other: TestOutcome) {
1160    for (suite_id, other_suite) in other.results {
1161        match base.results.entry(suite_id) {
1162            std::collections::btree_map::Entry::Vacant(e) => {
1163                e.insert(other_suite);
1164            }
1165            std::collections::btree_map::Entry::Occupied(mut e) => {
1166                let base_suite = e.get_mut();
1167                base_suite.test_results.extend(other_suite.test_results);
1168                base_suite.warnings.extend(other_suite.warnings);
1169                base_suite.duration = base_suite.duration.max(other_suite.duration);
1170            }
1171        }
1172    }
1173    if let Some(decoder) = other.last_run_decoder {
1174        base.last_run_decoder = Some(decoder);
1175    }
1176}
1177
1178/// Load persisted filter (with last test run failures) from file.
1179fn last_run_failures(config: &Config) -> Option<regex::Regex> {
1180    match fs::read_to_string(&config.test_failures_file) {
1181        Ok(filter) => Regex::new(&filter)
1182            .inspect_err(|e| {
1183                _ = sh_warn!(
1184                    "failed to parse test filter from {:?}: {e}",
1185                    config.test_failures_file
1186                )
1187            })
1188            .ok(),
1189        Err(_) => None,
1190    }
1191}
1192
1193/// Persist filter with last test run failures (only if there's any failure).
1194fn persist_run_failures(config: &Config, outcome: &TestOutcome) {
1195    if outcome.failed() > 0 && fs::create_file(&config.test_failures_file).is_ok() {
1196        let mut filter = String::new();
1197        let mut failures = outcome.failures().peekable();
1198        while let Some((test_name, _)) = failures.next() {
1199            if test_name.is_any_test()
1200                && let Some(test_match) = test_name.split('(').next()
1201            {
1202                filter.push_str(test_match);
1203                if failures.peek().is_some() {
1204                    filter.push('|');
1205                }
1206            }
1207        }
1208        let _ = fs::write(&config.test_failures_file, filter);
1209    }
1210}
1211
1212/// Generate test report in JUnit XML report format.
1213fn junit_xml_report(results: &BTreeMap<String, SuiteResult>, verbosity: u8) -> Report {
1214    let mut total_duration = Duration::default();
1215    let mut junit_report = Report::new("Test run");
1216    junit_report.set_timestamp(Utc::now());
1217    for (suite_name, suite_result) in results {
1218        let mut test_suite = TestSuite::new(suite_name);
1219        total_duration += suite_result.duration;
1220        test_suite.set_time(suite_result.duration);
1221        test_suite.set_system_out(suite_result.summary());
1222        for (test_name, test_result) in &suite_result.test_results {
1223            let mut test_status = match test_result.status {
1224                TestStatus::Success => TestCaseStatus::success(),
1225                TestStatus::Failure => TestCaseStatus::non_success(NonSuccessKind::Failure),
1226                TestStatus::Skipped => TestCaseStatus::skipped(),
1227            };
1228            if let Some(reason) = &test_result.reason {
1229                test_status.set_message(reason);
1230            }
1231
1232            let mut test_case = TestCase::new(test_name, test_status);
1233            test_case.set_time(test_result.duration);
1234
1235            let mut sys_out = String::new();
1236            let result_report = test_result.kind.report();
1237            write!(sys_out, "{test_result} {test_name} {result_report}").unwrap();
1238            if verbosity >= 2 && !test_result.logs.is_empty() {
1239                write!(sys_out, "\\nLogs:\\n").unwrap();
1240                let console_logs = decode_console_logs(&test_result.logs);
1241                for log in console_logs {
1242                    write!(sys_out, "  {log}\\n").unwrap();
1243                }
1244            }
1245
1246            test_case.set_system_out(sys_out);
1247            test_suite.add_test_case(test_case);
1248        }
1249        junit_report.add_test_suite(test_suite);
1250    }
1251    junit_report.set_time(total_duration);
1252    junit_report
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258    use foundry_config::Chain;
1259
1260    #[test]
1261    fn watch_parse() {
1262        let args: TestArgs = TestArgs::parse_from(["foundry-cli", "-vw"]);
1263        assert!(args.watch.watch.is_some());
1264    }
1265
1266    #[test]
1267    fn fuzz_seed() {
1268        let args: TestArgs = TestArgs::parse_from(["foundry-cli", "--fuzz-seed", "0x10"]);
1269        assert!(args.fuzz_seed.is_some());
1270    }
1271
1272    #[test]
1273    fn depth_trace() {
1274        let args: TestArgs = TestArgs::parse_from(["foundry-cli", "--trace-depth", "2"]);
1275        assert!(args.trace_depth.is_some());
1276    }
1277
1278    // <https://github.com/foundry-rs/foundry/issues/5913>
1279    #[test]
1280    fn fuzz_seed_exists() {
1281        let args: TestArgs =
1282            TestArgs::parse_from(["foundry-cli", "-vvv", "--gas-report", "--fuzz-seed", "0x10"]);
1283        assert!(args.fuzz_seed.is_some());
1284    }
1285
1286    #[test]
1287    fn fuzz_run() {
1288        let args: TestArgs =
1289            TestArgs::parse_from(["foundry-cli", "--fuzz-run", "10", "--fuzz-worker", "2"]);
1290        assert_eq!(args.fuzz_run, Some(10));
1291        assert_eq!(args.fuzz_worker, Some(2));
1292    }
1293
1294    #[test]
1295    fn extract_chain() {
1296        let test = |arg: &str, expected: Chain| {
1297            let args = TestArgs::parse_from(["foundry-cli", arg]);
1298            assert_eq!(args.evm.env.chain, Some(expected));
1299            let (config, evm_opts) = args.load_config_and_evm_opts().unwrap();
1300            assert_eq!(config.chain, Some(expected));
1301            assert_eq!(evm_opts.env.chain_id, Some(expected.id()));
1302        };
1303        test("--chain-id=1", Chain::mainnet());
1304        test("--chain-id=42", Chain::from_id(42));
1305    }
1306}