1use crate::{
4 fuzz::{BaseCounterExample, FuzzedCases},
5 gas_report::GasReport,
6};
7use alloy_primitives::{
8 Address, Log,
9 map::{AddressHashMap, HashMap},
10};
11use eyre::Report;
12use foundry_common::{evm::Breakpoints, get_contract_name, get_file_name, shell};
13use foundry_evm::{
14 coverage::HitMaps,
15 decode::SkipReason,
16 executors::{RawCallResult, invariant::InvariantMetrics},
17 fuzz::{CounterExample, FuzzCase, FuzzFixtures, FuzzTestResult},
18 traces::{CallTraceArena, CallTraceDecoder, TraceKind, Traces},
19};
20use serde::{Deserialize, Serialize};
21use std::{
22 collections::{BTreeMap, HashMap as Map},
23 fmt::{self, Write},
24 time::Duration,
25};
26use yansi::Paint;
27
28#[derive(Clone, Debug)]
30pub struct TestOutcome {
31 pub results: BTreeMap<String, SuiteResult>,
35 pub allow_failure: bool,
37 pub last_run_decoder: Option<CallTraceDecoder>,
43 pub gas_report: Option<GasReport>,
45}
46
47impl TestOutcome {
48 pub fn new(results: BTreeMap<String, SuiteResult>, allow_failure: bool) -> Self {
50 Self { results, allow_failure, last_run_decoder: None, gas_report: None }
51 }
52
53 pub fn empty(allow_failure: bool) -> Self {
55 Self::new(BTreeMap::new(), allow_failure)
56 }
57
58 pub fn successes(&self) -> impl Iterator<Item = (&String, &TestResult)> {
60 self.tests().filter(|(_, t)| t.status.is_success())
61 }
62
63 pub fn skips(&self) -> impl Iterator<Item = (&String, &TestResult)> {
65 self.tests().filter(|(_, t)| t.status.is_skipped())
66 }
67
68 pub fn failures(&self) -> impl Iterator<Item = (&String, &TestResult)> {
70 self.tests().filter(|(_, t)| t.status.is_failure())
71 }
72
73 pub fn tests(&self) -> impl Iterator<Item = (&String, &TestResult)> {
75 self.results.values().flat_map(|suite| suite.tests())
76 }
77
78 pub fn into_tests_cloned(&self) -> impl Iterator<Item = SuiteTestResult> + '_ {
81 self.results
82 .iter()
83 .flat_map(|(file, suite)| {
84 suite
85 .test_results
86 .iter()
87 .map(move |(sig, result)| (file.clone(), sig.clone(), result.clone()))
88 })
89 .map(|(artifact_id, signature, result)| SuiteTestResult {
90 artifact_id,
91 signature,
92 result,
93 })
94 }
95
96 pub fn into_tests(self) -> impl Iterator<Item = SuiteTestResult> {
98 self.results
99 .into_iter()
100 .flat_map(|(file, suite)| {
101 suite.test_results.into_iter().map(move |t| (file.clone(), t))
102 })
103 .map(|(artifact_id, (signature, result))| SuiteTestResult {
104 artifact_id,
105 signature,
106 result,
107 })
108 }
109
110 pub fn passed(&self) -> usize {
112 self.successes().count()
113 }
114
115 pub fn skipped(&self) -> usize {
117 self.skips().count()
118 }
119
120 pub fn failed(&self) -> usize {
122 self.failures().count()
123 }
124
125 pub fn total_time(&self) -> Duration {
129 self.results.values().map(|suite| suite.duration).sum()
130 }
131
132 pub fn summary(&self, wall_clock_time: Duration) -> String {
134 let num_test_suites = self.results.len();
135 let suites = if num_test_suites == 1 { "suite" } else { "suites" };
136 let total_passed = self.passed();
137 let total_failed = self.failed();
138 let total_skipped = self.skipped();
139 let total_tests = total_passed + total_failed + total_skipped;
140 format!(
141 "\nRan {} test {} in {:.2?} ({:.2?} CPU time): {} tests passed, {} failed, {} skipped ({} total tests)",
142 num_test_suites,
143 suites,
144 wall_clock_time,
145 self.total_time(),
146 total_passed.green(),
147 total_failed.red(),
148 total_skipped.yellow(),
149 total_tests
150 )
151 }
152
153 pub fn ensure_ok(&self, silent: bool) -> eyre::Result<()> {
155 let outcome = self;
156 let failures = outcome.failures().count();
157 if outcome.allow_failure || failures == 0 {
158 return Ok(());
159 }
160
161 if shell::is_quiet() || silent {
162 std::process::exit(1);
164 }
165
166 sh_println!("\nFailing tests:")?;
167 for (suite_name, suite) in &outcome.results {
168 let failed = suite.failed();
169 if failed == 0 {
170 continue;
171 }
172
173 let term = if failed > 1 { "tests" } else { "test" };
174 sh_println!("Encountered {failed} failing {term} in {suite_name}")?;
175 for (name, result) in suite.failures() {
176 sh_println!("{}", result.short_result(name))?;
177 }
178 sh_println!()?;
179 }
180 let successes = outcome.passed();
181 sh_println!(
182 "Encountered a total of {} failing tests, {} tests succeeded",
183 failures.to_string().red(),
184 successes.to_string().green()
185 )?;
186
187 std::process::exit(1);
189 }
190
191 pub fn remove_first(&mut self) -> Option<(String, String, TestResult)> {
193 self.results.iter_mut().find_map(|(suite_name, suite)| {
194 if let Some(test_name) = suite.test_results.keys().next().cloned() {
195 let result = suite.test_results.remove(&test_name).unwrap();
196 Some((suite_name.clone(), test_name, result))
197 } else {
198 None
199 }
200 })
201 }
202}
203
204#[derive(Clone, Debug, Serialize)]
206pub struct SuiteResult {
207 #[serde(with = "foundry_common::serde_helpers::duration")]
209 pub duration: Duration,
210 pub test_results: BTreeMap<String, TestResult>,
212 pub warnings: Vec<String>,
214}
215
216impl SuiteResult {
217 pub fn new(
218 duration: Duration,
219 test_results: BTreeMap<String, TestResult>,
220 mut warnings: Vec<String>,
221 ) -> Self {
222 let mut deprecated_cheatcodes = HashMap::new();
224 for test_result in test_results.values() {
225 deprecated_cheatcodes.extend(test_result.deprecated_cheatcodes.clone());
226 }
227 if !deprecated_cheatcodes.is_empty() {
228 let mut warning =
229 "the following cheatcode(s) are deprecated and will be removed in future versions:"
230 .to_string();
231 for (cheatcode, reason) in deprecated_cheatcodes {
232 write!(warning, "\n {cheatcode}").unwrap();
233 if let Some(reason) = reason {
234 write!(warning, ": {reason}").unwrap();
235 }
236 }
237 warnings.push(warning);
238 }
239
240 Self { duration, test_results, warnings }
241 }
242
243 pub fn successes(&self) -> impl Iterator<Item = (&String, &TestResult)> {
245 self.tests().filter(|(_, t)| t.status.is_success())
246 }
247
248 pub fn skips(&self) -> impl Iterator<Item = (&String, &TestResult)> {
250 self.tests().filter(|(_, t)| t.status.is_skipped())
251 }
252
253 pub fn failures(&self) -> impl Iterator<Item = (&String, &TestResult)> {
255 self.tests().filter(|(_, t)| t.status.is_failure())
256 }
257
258 pub fn passed(&self) -> usize {
260 self.successes().count()
261 }
262
263 pub fn skipped(&self) -> usize {
265 self.skips().count()
266 }
267
268 pub fn failed(&self) -> usize {
270 self.failures().count()
271 }
272
273 pub fn tests(&self) -> impl Iterator<Item = (&String, &TestResult)> {
275 self.test_results.iter()
276 }
277
278 pub fn is_empty(&self) -> bool {
280 self.test_results.is_empty()
281 }
282
283 pub fn len(&self) -> usize {
285 self.test_results.len()
286 }
287
288 pub fn total_time(&self) -> Duration {
292 self.test_results.values().map(|result| result.duration).sum()
293 }
294
295 pub fn summary(&self) -> String {
297 let failed = self.failed();
298 let result = if failed == 0 { "ok".green() } else { "FAILED".red() };
299 format!(
300 "Suite result: {}. {} passed; {} failed; {} skipped; finished in {:.2?} ({:.2?} CPU time)",
301 result,
302 self.passed().green(),
303 failed.red(),
304 self.skipped().yellow(),
305 self.duration,
306 self.total_time(),
307 )
308 }
309}
310
311#[derive(Clone, Debug)]
315pub struct SuiteTestResult {
316 pub artifact_id: String,
319 pub signature: String,
321 pub result: TestResult,
323}
324
325impl SuiteTestResult {
326 pub fn gas_used(&self) -> u64 {
328 self.result.kind.report().gas()
329 }
330
331 pub fn contract_name(&self) -> &str {
333 get_contract_name(&self.artifact_id)
334 }
335
336 pub fn file_name(&self) -> &str {
338 get_file_name(&self.artifact_id)
339 }
340}
341
342#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
344pub enum TestStatus {
345 Success,
346 #[default]
347 Failure,
348 Skipped,
349}
350
351impl TestStatus {
352 #[inline]
354 pub fn is_success(self) -> bool {
355 matches!(self, Self::Success)
356 }
357
358 #[inline]
360 pub fn is_failure(self) -> bool {
361 matches!(self, Self::Failure)
362 }
363
364 #[inline]
366 pub fn is_skipped(self) -> bool {
367 matches!(self, Self::Skipped)
368 }
369}
370
371#[derive(Clone, Debug, Default, Serialize, Deserialize)]
373pub struct TestResult {
374 pub status: TestStatus,
379
380 pub reason: Option<String>,
383
384 pub counterexample: Option<CounterExample>,
386
387 pub logs: Vec<Log>,
390
391 pub decoded_logs: Vec<String>,
394
395 pub kind: TestKind,
397
398 pub traces: Traces,
400
401 #[serde(skip)]
403 pub gas_report_traces: Vec<Vec<CallTraceArena>>,
404
405 #[serde(skip)]
407 pub line_coverage: Option<HitMaps>,
408
409 #[serde(rename = "labeled_addresses")] pub labels: AddressHashMap<String>,
412
413 #[serde(with = "foundry_common::serde_helpers::duration")]
414 pub duration: Duration,
415
416 pub breakpoints: Breakpoints,
418
419 pub gas_snapshots: BTreeMap<String, BTreeMap<String, String>>,
421
422 #[serde(skip)]
424 pub deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>,
425}
426
427impl fmt::Display for TestResult {
428 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429 match self.status {
430 TestStatus::Success => "[PASS]".green().fmt(f),
431 TestStatus::Skipped => {
432 let mut s = String::from("[SKIP");
433 if let Some(reason) = &self.reason {
434 write!(s, ": {reason}").unwrap();
435 }
436 s.push(']');
437 s.yellow().fmt(f)
438 }
439 TestStatus::Failure => {
440 let mut s = String::from("[FAIL");
441 if self.reason.is_some() || self.counterexample.is_some() {
442 if let Some(reason) = &self.reason {
443 write!(s, ": {reason}").unwrap();
444 }
445
446 if let Some(counterexample) = &self.counterexample {
447 match counterexample {
448 CounterExample::Single(ex) => {
449 write!(s, "; counterexample: {ex}]").unwrap();
450 }
451 CounterExample::Sequence(original, sequence) => {
452 s.push_str(
453 format!(
454 "]\n\t[Sequence] (original: {original}, shrunk: {})\n",
455 sequence.len()
456 )
457 .as_str(),
458 );
459 for ex in sequence {
460 writeln!(s, "{ex}").unwrap();
461 }
462 }
463 }
464 } else {
465 s.push(']');
466 }
467 } else {
468 s.push(']');
469 }
470 s.red().wrap().fmt(f)
471 }
472 }
473 }
474}
475
476macro_rules! extend {
477 ($a:expr, $b:expr, $trace_kind:expr) => {
478 $a.logs.extend($b.logs);
479 $a.labels.extend($b.labels);
480 $a.traces.extend($b.traces.map(|traces| ($trace_kind, traces)));
481 $a.merge_coverages($b.line_coverage);
482 };
483}
484
485impl TestResult {
486 pub fn new(setup: &TestSetup) -> Self {
488 Self {
489 labels: setup.labels.clone(),
490 logs: setup.logs.clone(),
491 traces: setup.traces.clone(),
492 line_coverage: setup.coverage.clone(),
493 ..Default::default()
494 }
495 }
496
497 pub fn fail(reason: String) -> Self {
499 Self { status: TestStatus::Failure, reason: Some(reason), ..Default::default() }
500 }
501
502 pub fn setup_result(setup: TestSetup) -> Self {
504 let TestSetup {
505 address: _,
506 fuzz_fixtures: _,
507 logs,
508 labels,
509 traces,
510 coverage,
511 deployed_libs: _,
512 reason,
513 skipped,
514 deployment_failure: _,
515 } = setup;
516 Self {
517 status: if skipped { TestStatus::Skipped } else { TestStatus::Failure },
518 reason,
519 logs,
520 traces,
521 line_coverage: coverage,
522 labels,
523 ..Default::default()
524 }
525 }
526
527 pub fn single_skip(&mut self, reason: SkipReason) {
529 self.status = TestStatus::Skipped;
530 self.reason = reason.0;
531 }
532
533 pub fn single_fail(&mut self, reason: Option<String>) {
535 self.status = TestStatus::Failure;
536 self.reason = reason;
537 }
538
539 pub fn single_result(
542 &mut self,
543 success: bool,
544 reason: Option<String>,
545 raw_call_result: RawCallResult,
546 ) {
547 self.kind =
548 TestKind::Unit { gas: raw_call_result.gas_used.wrapping_sub(raw_call_result.stipend) };
549
550 extend!(self, raw_call_result, TraceKind::Execution);
551
552 self.status = match success {
553 true => TestStatus::Success,
554 false => TestStatus::Failure,
555 };
556 self.reason = reason;
557 self.duration = Duration::default();
558 self.gas_report_traces = Vec::new();
559
560 if let Some(cheatcodes) = raw_call_result.cheatcodes {
561 self.breakpoints = cheatcodes.breakpoints;
562 self.gas_snapshots = cheatcodes.gas_snapshots;
563 self.deprecated_cheatcodes = cheatcodes.deprecated;
564 }
565 }
566
567 pub fn fuzz_result(&mut self, result: FuzzTestResult) {
570 self.kind = TestKind::Fuzz {
571 median_gas: result.median_gas(false),
572 mean_gas: result.mean_gas(false),
573 first_case: result.first_case,
574 runs: result.gas_by_case.len(),
575 failed_corpus_replays: result.failed_corpus_replays,
576 };
577
578 extend!(self, result, TraceKind::Execution);
580
581 self.status = if result.skipped {
582 TestStatus::Skipped
583 } else if result.success {
584 TestStatus::Success
585 } else {
586 TestStatus::Failure
587 };
588 self.reason = result.reason;
589 self.counterexample = result.counterexample;
590 self.duration = Duration::default();
591 self.gas_report_traces = result.gas_report_traces.into_iter().map(|t| vec![t]).collect();
592 self.breakpoints = result.breakpoints.unwrap_or_default();
593 self.deprecated_cheatcodes = result.deprecated_cheatcodes;
594 }
595
596 pub fn fuzz_setup_fail(&mut self, e: Report) {
598 self.kind = TestKind::Fuzz {
599 first_case: Default::default(),
600 runs: 0,
601 mean_gas: 0,
602 median_gas: 0,
603 failed_corpus_replays: 0,
604 };
605 self.status = TestStatus::Failure;
606 self.reason = Some(format!("failed to set up fuzz testing environment: {e}"));
607 }
608
609 pub fn invariant_skip(&mut self, reason: SkipReason) {
611 self.kind = TestKind::Invariant {
612 runs: 1,
613 calls: 1,
614 reverts: 1,
615 metrics: HashMap::default(),
616 failed_corpus_replays: 0,
617 };
618 self.status = TestStatus::Skipped;
619 self.reason = reason.0;
620 }
621
622 pub fn invariant_replay_fail(
624 &mut self,
625 replayed_entirely: bool,
626 invariant_name: &String,
627 call_sequence: Vec<BaseCounterExample>,
628 ) {
629 self.kind = TestKind::Invariant {
630 runs: 1,
631 calls: 1,
632 reverts: 1,
633 metrics: HashMap::default(),
634 failed_corpus_replays: 0,
635 };
636 self.status = TestStatus::Failure;
637 self.reason = if replayed_entirely {
638 Some(format!("{invariant_name} replay failure"))
639 } else {
640 Some(format!("{invariant_name} persisted failure revert"))
641 };
642 self.counterexample = Some(CounterExample::Sequence(call_sequence.len(), call_sequence));
643 }
644
645 pub fn invariant_setup_fail(&mut self, e: Report) {
647 self.kind = TestKind::Invariant {
648 runs: 0,
649 calls: 0,
650 reverts: 0,
651 metrics: HashMap::default(),
652 failed_corpus_replays: 0,
653 };
654 self.status = TestStatus::Failure;
655 self.reason = Some(format!("failed to set up invariant testing environment: {e}"));
656 }
657
658 #[expect(clippy::too_many_arguments)]
660 pub fn invariant_result(
661 &mut self,
662 gas_report_traces: Vec<Vec<CallTraceArena>>,
663 success: bool,
664 reason: Option<String>,
665 counterexample: Option<CounterExample>,
666 cases: Vec<FuzzedCases>,
667 reverts: usize,
668 metrics: Map<String, InvariantMetrics>,
669 failed_corpus_replays: usize,
670 ) {
671 self.kind = TestKind::Invariant {
672 runs: cases.len(),
673 calls: cases.iter().map(|sequence| sequence.cases().len()).sum(),
674 reverts,
675 metrics,
676 failed_corpus_replays,
677 };
678 self.status = match success {
679 true => TestStatus::Success,
680 false => TestStatus::Failure,
681 };
682 self.reason = reason;
683 self.counterexample = counterexample;
684 self.gas_report_traces = gas_report_traces;
685 }
686
687 pub fn table_result(&mut self, result: FuzzTestResult) {
690 self.kind = TestKind::Table {
691 median_gas: result.median_gas(false),
692 mean_gas: result.mean_gas(false),
693 runs: result.gas_by_case.len(),
694 };
695
696 extend!(self, result, TraceKind::Execution);
698
699 self.status = if result.skipped {
700 TestStatus::Skipped
701 } else if result.success {
702 TestStatus::Success
703 } else {
704 TestStatus::Failure
705 };
706 self.reason = result.reason;
707 self.counterexample = result.counterexample;
708 self.duration = Duration::default();
709 self.gas_report_traces = result.gas_report_traces.into_iter().map(|t| vec![t]).collect();
710 self.breakpoints = result.breakpoints.unwrap_or_default();
711 self.deprecated_cheatcodes = result.deprecated_cheatcodes;
712 }
713
714 pub fn is_fuzz(&self) -> bool {
716 matches!(self.kind, TestKind::Fuzz { .. })
717 }
718
719 pub fn short_result(&self, name: &str) -> String {
721 format!("{self} {name} {}", self.kind.report())
722 }
723
724 pub fn extend(&mut self, call_result: RawCallResult) {
726 extend!(self, call_result, TraceKind::Execution);
727 }
728
729 pub fn merge_coverages(&mut self, other_coverage: Option<HitMaps>) {
731 HitMaps::merge_opt(&mut self.line_coverage, other_coverage);
732 }
733}
734
735#[derive(Clone, Debug, PartialEq, Eq)]
737pub enum TestKindReport {
738 Unit {
739 gas: u64,
740 },
741 Fuzz {
742 runs: usize,
743 mean_gas: u64,
744 median_gas: u64,
745 failed_corpus_replays: usize,
746 },
747 Invariant {
748 runs: usize,
749 calls: usize,
750 reverts: usize,
751 metrics: Map<String, InvariantMetrics>,
752 failed_corpus_replays: usize,
753 },
754 Table {
755 runs: usize,
756 mean_gas: u64,
757 median_gas: u64,
758 },
759}
760
761impl fmt::Display for TestKindReport {
762 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
763 match self {
764 Self::Unit { gas } => {
765 write!(f, "(gas: {gas})")
766 }
767 Self::Fuzz { runs, mean_gas, median_gas, failed_corpus_replays } => {
768 if *failed_corpus_replays != 0 {
769 write!(
770 f,
771 "(runs: {runs}, μ: {mean_gas}, ~: {median_gas}, failed corpus replays: {failed_corpus_replays})"
772 )
773 } else {
774 write!(f, "(runs: {runs}, μ: {mean_gas}, ~: {median_gas})")
775 }
776 }
777 Self::Invariant { runs, calls, reverts, metrics: _, failed_corpus_replays } => {
778 if *failed_corpus_replays != 0 {
779 write!(
780 f,
781 "(runs: {runs}, calls: {calls}, reverts: {reverts}, failed corpus replays: {failed_corpus_replays})"
782 )
783 } else {
784 write!(f, "(runs: {runs}, calls: {calls}, reverts: {reverts})")
785 }
786 }
787 Self::Table { runs, mean_gas, median_gas } => {
788 write!(f, "(runs: {runs}, μ: {mean_gas}, ~: {median_gas})")
789 }
790 }
791 }
792}
793
794impl TestKindReport {
795 pub fn gas(&self) -> u64 {
797 match *self {
798 Self::Unit { gas } => gas,
799 Self::Fuzz { median_gas, .. } | Self::Table { median_gas, .. } => median_gas,
801 Self::Invariant { .. } => 0,
803 }
804 }
805}
806
807#[derive(Clone, Debug, Serialize, Deserialize)]
809pub enum TestKind {
810 Unit { gas: u64 },
812 Fuzz {
814 first_case: FuzzCase,
816 runs: usize,
817 mean_gas: u64,
818 median_gas: u64,
819 failed_corpus_replays: usize,
820 },
821 Invariant {
823 runs: usize,
824 calls: usize,
825 reverts: usize,
826 metrics: Map<String, InvariantMetrics>,
827 failed_corpus_replays: usize,
828 },
829 Table { runs: usize, mean_gas: u64, median_gas: u64 },
831}
832
833impl Default for TestKind {
834 fn default() -> Self {
835 Self::Unit { gas: 0 }
836 }
837}
838
839impl TestKind {
840 pub fn report(&self) -> TestKindReport {
842 match self {
843 Self::Unit { gas } => TestKindReport::Unit { gas: *gas },
844 Self::Fuzz { first_case: _, runs, mean_gas, median_gas, failed_corpus_replays } => {
845 TestKindReport::Fuzz {
846 runs: *runs,
847 mean_gas: *mean_gas,
848 median_gas: *median_gas,
849 failed_corpus_replays: *failed_corpus_replays,
850 }
851 }
852 Self::Invariant { runs, calls, reverts, metrics: _, failed_corpus_replays } => {
853 TestKindReport::Invariant {
854 runs: *runs,
855 calls: *calls,
856 reverts: *reverts,
857 metrics: HashMap::default(),
858 failed_corpus_replays: *failed_corpus_replays,
859 }
860 }
861 Self::Table { runs, mean_gas, median_gas } => {
862 TestKindReport::Table { runs: *runs, mean_gas: *mean_gas, median_gas: *median_gas }
863 }
864 }
865 }
866}
867
868#[derive(Clone, Debug, Default)]
873pub struct TestSetup {
874 pub address: Address,
876 pub fuzz_fixtures: FuzzFixtures,
878
879 pub logs: Vec<Log>,
881 pub labels: AddressHashMap<String>,
883 pub traces: Traces,
885 pub coverage: Option<HitMaps>,
887 pub deployed_libs: Vec<Address>,
889
890 pub reason: Option<String>,
892 pub skipped: bool,
894 pub deployment_failure: bool,
896}
897
898impl TestSetup {
899 pub fn failed(reason: String) -> Self {
900 Self { reason: Some(reason), ..Default::default() }
901 }
902
903 pub fn skipped(reason: String) -> Self {
904 Self { reason: Some(reason), skipped: true, ..Default::default() }
905 }
906
907 pub fn extend(&mut self, raw: RawCallResult, trace_kind: TraceKind) {
908 extend!(self, raw, trace_kind);
909 }
910
911 pub fn merge_coverages(&mut self, other_coverage: Option<HitMaps>) {
912 HitMaps::merge_opt(&mut self.coverage, other_coverage);
913 }
914}