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
70foundry_config::merge_impl_figment_convert!(TestArgs, build, evm);
72
73#[derive(Clone, Debug, Parser)]
75#[command(next_help_heading = "Test options")]
76pub struct TestArgs {
77 #[command(flatten)]
79 pub global: GlobalArgs,
80
81 #[arg(value_hint = ValueHint::FilePath)]
83 pub path: Option<GlobMatcher>,
84
85 #[arg(long, conflicts_with_all = ["flamegraph", "flamechart", "decode_internal", "rerun"])]
92 debug: bool,
93
94 #[arg(long)]
99 flamegraph: bool,
100
101 #[arg(long, conflicts_with = "flamegraph")]
106 flamechart: bool,
107
108 #[arg(long)]
115 decode_internal: bool,
116
117 #[arg(
119 long,
120 requires = "debug",
121 value_hint = ValueHint::FilePath,
122 value_name = "PATH"
123 )]
124 dump: Option<PathBuf>,
125
126 #[arg(long, env = "FORGE_GAS_REPORT")]
128 gas_report: bool,
129
130 #[arg(long, env = "FORGE_SNAPSHOT_CHECK")]
132 gas_snapshot_check: Option<bool>,
133
134 #[arg(long, env = "FORGE_SNAPSHOT_EMIT")]
136 gas_snapshot_emit: Option<bool>,
137
138 #[arg(long, env = "FORGE_ALLOW_FAILURE")]
140 allow_failure: bool,
141
142 #[arg(long, short, env = "FORGE_SUPPRESS_SUCCESSFUL_TRACES", help_heading = "Display options")]
144 suppress_successful_traces: bool,
145
146 #[arg(long)]
148 trace_depth: Option<usize>,
149
150 #[arg(long, conflicts_with_all = ["quiet", "json", "gas_report", "summary", "list", "show_progress"], help_heading = "Display options")]
152 pub junit: bool,
153
154 #[arg(long)]
156 pub fail_fast: bool,
157
158 #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")]
160 etherscan_api_key: Option<String>,
161
162 #[arg(long, short, conflicts_with_all = ["show_progress", "decode_internal", "summary"], help_heading = "Display options")]
164 list: bool,
165
166 #[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 #[arg(long, env = "FOUNDRY_FUZZ_RUN", value_name = "RUN")]
175 pub fuzz_run: Option<u32>,
176
177 #[arg(long, env = "FOUNDRY_FUZZ_WORKER", value_name = "WORKER", requires = "fuzz_run")]
179 pub fuzz_worker: Option<u32>,
180
181 #[arg(long, env = "FOUNDRY_FUZZ_TIMEOUT", value_name = "TIMEOUT")]
183 pub fuzz_timeout: Option<u64>,
184
185 #[arg(long)]
187 pub fuzz_input_file: Option<String>,
188
189 #[arg(long, conflicts_with_all = ["quiet", "json"], help_heading = "Display options")]
191 pub show_progress: bool,
192
193 #[arg(long)]
196 pub rerun: bool,
197
198 #[arg(long, help_heading = "Display options")]
200 pub summary: bool,
201
202 #[arg(long, help_heading = "Display options", requires = "summary")]
204 pub detailed: bool,
205
206 #[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 #[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 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 pub async fn compile_and_run(&mut self) -> Result<TestOutcome> {
276 let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
278
279 if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings
281 {
282 config = self.load_config()?;
284 }
285
286 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 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 if self.gas_report {
319 evm_opts.isolate = true;
320 } else {
321 config.fuzz.gas_report_samples = 0;
323 config.invariant.gas_report_samples = 0;
324 }
325
326 config.fuzz.seed = config
328 .fuzz
329 .seed
330 .or_else(|| Some(U256::from_be_bytes(rand::rng().random::<[u8; 32]>())));
331
332 let should_debug = self.debug;
334 let should_draw = self.flamegraph || self.flamechart;
335
336 if (self.gas_report && evm_opts.verbosity < 3) || self.flamegraph || self.flamechart {
338 evm_opts.verbosity = 3;
339 }
340
341 if should_draw && !self.decode_internal {
343 self.decode_internal = true;
344 }
345
346 let decode_internal = if self.decode_internal {
348 InternalTraceMode::Simple
351 } else {
352 InternalTraceMode::None
353 };
354
355 evm_opts.infer_network_from_fork().await;
357
358 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 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 let all_override_networks = override_networks.clone();
379 let multi_pass_timer = Instant::now();
380
381 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 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 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 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 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 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 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 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 #[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 #[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 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 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 if let Some(test_pattern) = &filter.args().test_pattern {
632 let test_name = test_pattern.as_str();
633 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 num_filtered == 1 && self.decode_internal {
665 runner.decode_internal = InternalTraceMode::Full;
666 }
667
668 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 test_result.decoded_logs = decode_console_logs(&test_result.logs);
676 } else {
677 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 let is_multi_pass = !runner.tcfg.multi_network.all_override_networks.is_empty();
704
705 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 let mut identifier = TraceIdentifiers::new().with_local(&known_contracts);
716
717 if !self.gas_report && remote_chain.is_some() {
721 identifier = identifier.with_external(&config, remote_chain)?;
722 }
723
724 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 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 if is_multi_pass && !has_tests && suite_result.warnings.is_empty() {
766 continue;
767 }
768
769 decoder.clear_addresses();
771
772 let identify_addresses = verbosity >= 3
774 || self.gas_report
775 || self.debug
776 || self.flamegraph
777 || self.flamechart;
778
779 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 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 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 if verbosity >= 2 && show_traces {
808 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 any_test_failed |= result.status == TestStatus::Failure;
823
824 decoder.clear_addresses();
826 decoder.labels.extend(result.labels.iter().map(|(k, v)| (*k, v.clone())));
827
828 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 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 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 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 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 result.gas_report_traces = Default::default();
917
918 for (group, new_snapshots) in &result.gas_snapshots {
920 gas_snapshots.entry(group.clone()).or_default().extend(new_snapshots.clone());
921 }
922 }
923
924 if !gas_snapshots.is_empty() {
926 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 !&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 if self.gas_snapshot_emit.unwrap_or(config.gas_snapshot_emit) {
995 fs::create_dir_all(&config.snapshots)?;
997
998 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 if !silent && has_tests {
1011 sh_println!("{}", suite_result.summary())?;
1012 }
1013
1014 outcome.results.insert(contract_name, suite_result);
1016
1017 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 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_run_failures(&config, &outcome);
1056
1057 Ok(outcome)
1058 }
1059
1060 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 pub const fn is_watch(&self) -> bool {
1079 self.watch.watch.is_some()
1080 }
1081
1082 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
1134fn 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
1155fn 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
1178fn 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
1193fn 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
1212fn 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 #[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}