1use crate::{fuzz::BaseCounterExample, gas_report::GasReport};
4use alloy_primitives::{
5 Address, Bytes, I256, Log, Selector, U256,
6 map::{AddressHashMap, HashMap},
7};
8use eyre::Report;
9use foundry_common::{ContractsByArtifact, get_contract_name, get_file_name, shell};
10use foundry_evm::{
11 core::{Breakpoints, evm::FoundryEvmNetwork},
12 coverage::HitMaps,
13 decode::SkipReason,
14 executors::{RawCallResult, invariant::InvariantMetrics},
15 fuzz::{CounterExample, FuzzCase, FuzzFixtures, FuzzTestResult},
16 traces::{CallTraceArena, CallTraceDecoder, TraceKind, Traces},
17};
18use foundry_evm_symbolic::{PortfolioDiagnostics, SymbolicStats};
19use serde::{Deserialize, Serialize};
20use std::{
21 borrow::Cow,
22 collections::{BTreeMap, HashMap as Map},
23 fmt::{self, Write},
24 time::Duration,
25};
26use yansi::Paint;
27
28pub(crate) fn invariant_campaign_display_name(contract_name: &str) -> String {
29 format!("{contract_name} invariants")
30}
31
32const INVARIANT_CAMPAIGN_FALLBACK_NAME: &str = "Invariant campaign";
33
34#[derive(Clone, Debug)]
36pub struct TestOutcome {
37 pub results: BTreeMap<String, SuiteResult>,
41 pub allow_failure: bool,
43 pub last_run_decoder: Option<CallTraceDecoder>,
49 pub gas_report: Option<GasReport>,
51 pub known_contracts: Option<ContractsByArtifact>,
53 pub fuzz_seed: Option<U256>,
55}
56
57impl TestOutcome {
58 pub const fn new(
60 known_contracts: Option<ContractsByArtifact>,
61 results: BTreeMap<String, SuiteResult>,
62 allow_failure: bool,
63 fuzz_seed: Option<U256>,
64 ) -> Self {
65 Self {
66 results,
67 allow_failure,
68 last_run_decoder: None,
69 gas_report: None,
70 known_contracts,
71 fuzz_seed,
72 }
73 }
74
75 pub const fn empty(known_contracts: Option<ContractsByArtifact>, allow_failure: bool) -> Self {
77 Self::new(known_contracts, BTreeMap::new(), allow_failure, None)
78 }
79
80 pub fn successes(&self) -> impl Iterator<Item = (&String, &TestResult)> {
82 self.tests().filter(|(_, t)| t.status.is_success())
83 }
84
85 pub fn skips(&self) -> impl Iterator<Item = (&String, &TestResult)> {
87 self.tests().filter(|(_, t)| t.status.is_skipped())
88 }
89
90 pub fn failures(&self) -> impl Iterator<Item = (&String, &TestResult)> {
92 self.tests().filter(|(_, t)| t.status.is_failure())
93 }
94
95 pub fn tests(&self) -> impl Iterator<Item = (&String, &TestResult)> {
97 self.results.values().flat_map(|suite| suite.tests())
98 }
99
100 pub fn symbolic_portfolio_diagnostics(&self) -> Option<PortfolioDiagnostics> {
102 let mut diagnostics = PortfolioDiagnostics::default();
103 for (_, result) in self.tests() {
104 if let Some(result_diagnostics) = &result.symbolic_portfolio_diagnostics {
105 diagnostics.merge(result_diagnostics);
106 }
107 }
108 (!diagnostics.is_empty()).then_some(diagnostics)
109 }
110
111 pub fn into_tests_cloned(&self) -> impl Iterator<Item = SuiteTestResult> + '_ {
114 self.results
115 .iter()
116 .flat_map(|(file, suite)| {
117 suite
118 .test_results
119 .iter()
120 .map(move |(sig, result)| (file.clone(), sig.clone(), result.clone()))
121 })
122 .map(|(artifact_id, signature, result)| SuiteTestResult {
123 artifact_id,
124 signature,
125 result,
126 })
127 }
128
129 pub fn into_tests(self) -> impl Iterator<Item = SuiteTestResult> {
131 self.results
132 .into_iter()
133 .flat_map(|(file, suite)| {
134 suite.test_results.into_iter().map(move |t| (file.clone(), t))
135 })
136 .map(|(artifact_id, (signature, result))| SuiteTestResult {
137 artifact_id,
138 signature,
139 result,
140 })
141 }
142
143 pub fn passed(&self) -> usize {
145 self.results.values().map(SuiteResult::passed).sum()
146 }
147
148 pub fn skipped(&self) -> usize {
150 self.results.values().map(SuiteResult::skipped).sum()
151 }
152
153 pub fn failed(&self) -> usize {
155 self.results.values().map(SuiteResult::failed).sum()
156 }
157
158 pub fn has_fuzz_failures(&self) -> bool {
160 self.failures().any(|(_, t)| t.kind.is_fuzz() || t.kind.is_invariant())
161 }
162
163 pub fn has_invariant_failures(&self) -> bool {
165 self.failures().any(|(_, t)| t.kind.is_invariant())
166 }
167
168 fn invariant_workers_hint(&self) -> Option<usize> {
169 let mut workers = self.failures().filter_map(|(_, result)| result.kind.invariant_workers());
170 let first = workers.next()?;
171 (first > 1 && workers.all(|workers| workers == first)).then_some(first)
172 }
173
174 pub fn total_time(&self) -> Duration {
178 self.results.values().map(|suite| suite.duration).sum()
179 }
180
181 pub fn summary(&self, wall_clock_time: Duration) -> String {
183 let num_test_suites = self.results.len();
184 let suites = if num_test_suites == 1 { "suite" } else { "suites" };
185 let total_passed = self.passed();
186 let total_failed = self.failed();
187 let total_skipped = self.skipped();
188 let total_tests = total_passed + total_failed + total_skipped;
189 format!(
190 "\nRan {} test {} in {:.2?} ({:.2?} CPU time): {} tests passed, {} failed, {} skipped ({} total tests)",
191 num_test_suites,
192 suites,
193 wall_clock_time,
194 self.total_time(),
195 total_passed.green(),
196 total_failed.red(),
197 total_skipped.yellow(),
198 total_tests
199 )
200 }
201
202 pub fn ensure_ok(&self, silent: bool) -> eyre::Result<()> {
208 let outcome = self;
209 let failures = outcome.failures().count();
210 if outcome.allow_failure || failures == 0 {
211 return Ok(());
212 }
213
214 if shell::is_quiet() || silent {
215 std::process::exit(test_failure_exit_code());
216 }
217
218 sh_println!("\nFailing tests:")?;
219 for (suite_name, suite) in &outcome.results {
220 let failed = suite.failed();
221 if failed == 0 {
222 continue;
223 }
224
225 let term = if failed > 1 { "tests" } else { "test" };
226 sh_println!("Encountered {failed} failing {term} in {suite_name}")?;
227 for (name, result) in suite.failures() {
228 sh_println!("{}", result.short_result_with_suite(name, suite_name))?;
229 }
230 sh_println!()?;
231 }
232 let successes = outcome.passed();
233 sh_println!(
234 "Encountered a total of {} failing tests, {} tests succeeded",
235 failures.to_string().red(),
236 successes.to_string().green()
237 )?;
238
239 let test_word = if failures == 1 { "test" } else { "tests" };
241 sh_println!(
242 "\nTip: Run {} to retry only the {} failed {}",
243 "`forge test --rerun`".cyan(),
244 failures,
245 test_word
246 )?;
247
248 if let Some(seed) = self.fuzz_seed
250 && outcome.has_fuzz_failures()
251 {
252 sh_println!(
253 "\nFuzz seed: {} (use {} to reproduce)",
254 format!("{seed:#x}").cyan(),
255 "`--fuzz-seed`".cyan()
256 )?;
257 if let Some(invariant_workers) = outcome.invariant_workers_hint() {
258 sh_println!(
259 "Invariant workers: {} (use {} to reproduce)",
260 invariant_workers,
261 format!("`--invariant-workers {invariant_workers}`").cyan()
262 )?;
263 }
264 }
265
266 std::process::exit(test_failure_exit_code());
267 }
268
269 pub fn remove_first(&mut self) -> Option<(String, String, TestResult)> {
271 self.results.iter_mut().find_map(|(suite_name, suite)| {
272 if let Some(test_name) = suite.test_results.keys().next().cloned() {
273 let result = suite.test_results.remove(&test_name).unwrap();
274 Some((suite_name.clone(), test_name, result))
275 } else {
276 None
277 }
278 })
279 }
280}
281
282fn test_failure_exit_code() -> i32 {
284 if foundry_cli::is_machine() { foundry_cli::ExitCode::TestFailure.to_i32() } else { 1 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn outcome_with_failed_invariant_workers(workers: &[usize]) -> TestOutcome {
292 let test_results = workers
293 .iter()
294 .enumerate()
295 .map(|(idx, workers)| {
296 (
297 format!("invariant{idx}()"),
298 TestResult {
299 status: TestStatus::Failure,
300 kind: TestKind::Invariant {
301 runs: 0,
302 calls: 0,
303 reverts: 0,
304 workers: *workers,
305 metrics: Map::new(),
306 failed_corpus_replays: 0,
307 optimization_best_value: None,
308 },
309 ..Default::default()
310 },
311 )
312 })
313 .collect();
314 TestOutcome::new(
315 None,
316 BTreeMap::from([(
317 "suite".to_string(),
318 SuiteResult::new(Duration::ZERO, test_results, Vec::new()),
319 )]),
320 false,
321 None,
322 )
323 }
324
325 #[test]
326 fn invariant_workers_hint_requires_matching_parallel_worker_counts() {
327 assert_eq!(
328 outcome_with_failed_invariant_workers(&[3, 3]).invariant_workers_hint(),
329 Some(3)
330 );
331 assert_eq!(outcome_with_failed_invariant_workers(&[2, 3]).invariant_workers_hint(), None);
332 assert_eq!(outcome_with_failed_invariant_workers(&[1]).invariant_workers_hint(), None);
333 }
334
335 #[test]
336 fn invariant_kind_deserializes_legacy_payload_without_workers() {
337 let kind = serde_json::from_value::<TestKind>(serde_json::json!({
338 "Invariant": {
339 "runs": 4,
340 "calls": 10,
341 "reverts": 0,
342 "metrics": {},
343 "failed_corpus_replays": 0,
344 "optimization_best_value": null
345 }
346 }))
347 .unwrap();
348
349 assert_eq!(kind.invariant_workers(), Some(1));
350 }
351}
352
353#[derive(Clone, Debug, Serialize)]
355pub struct SuiteResult {
356 #[serde(with = "foundry_common::serde_helpers::duration")]
358 pub duration: Duration,
359 pub test_results: BTreeMap<String, TestResult>,
361 pub warnings: Vec<String>,
363}
364
365impl SuiteResult {
366 pub fn new(
367 duration: Duration,
368 test_results: BTreeMap<String, TestResult>,
369 mut warnings: Vec<String>,
370 ) -> Self {
371 let mut deprecated_cheatcodes = HashMap::new();
373 for test_result in test_results.values() {
374 deprecated_cheatcodes.extend(test_result.deprecated_cheatcodes.clone());
375 }
376 if !deprecated_cheatcodes.is_empty() {
377 let mut warning =
378 "the following cheatcode(s) are deprecated and will be removed in future versions:"
379 .to_string();
380 for (cheatcode, reason) in deprecated_cheatcodes {
381 write!(warning, "\n {cheatcode}").unwrap();
382 if let Some(reason) = reason {
383 write!(warning, ": {reason}").unwrap();
384 }
385 }
386 warnings.push(warning);
387 }
388
389 Self { duration, test_results, warnings }
390 }
391
392 pub fn successes(&self) -> impl Iterator<Item = (&String, &TestResult)> {
394 self.tests().filter(|(_, t)| t.status.is_success())
395 }
396
397 pub fn skips(&self) -> impl Iterator<Item = (&String, &TestResult)> {
399 self.tests().filter(|(_, t)| t.status.is_skipped())
400 }
401
402 pub fn failures(&self) -> impl Iterator<Item = (&String, &TestResult)> {
404 self.tests().filter(|(_, t)| t.status.is_failure())
405 }
406
407 pub fn passed(&self) -> usize {
409 self.test_results.values().map(TestResult::passed_count).sum()
410 }
411
412 pub fn skipped(&self) -> usize {
414 self.test_results.values().map(TestResult::skipped_count).sum()
415 }
416
417 pub fn failed(&self) -> usize {
419 self.test_results.values().map(TestResult::failed_count).sum()
420 }
421
422 pub fn tests(&self) -> impl Iterator<Item = (&String, &TestResult)> {
424 self.test_results.iter()
425 }
426
427 pub fn is_empty(&self) -> bool {
429 self.test_results.is_empty()
430 }
431
432 pub fn len(&self) -> usize {
434 self.test_results.values().map(TestResult::logical_count).sum()
435 }
436
437 pub fn total_time(&self) -> Duration {
441 self.test_results.values().map(|result| result.duration).sum()
442 }
443
444 pub fn summary(&self) -> String {
446 let failed = self.failed();
447 let result = if failed == 0 { "ok".green() } else { "FAILED".red() };
448 format!(
449 "Suite result: {}. {} passed; {} failed; {} skipped; finished in {:.2?} ({:.2?} CPU time)",
450 result,
451 self.passed().green(),
452 failed.red(),
453 self.skipped().yellow(),
454 self.duration,
455 self.total_time(),
456 )
457 }
458}
459
460#[derive(Clone, Debug)]
464pub struct SuiteTestResult {
465 pub artifact_id: String,
468 pub signature: String,
470 pub result: TestResult,
472}
473
474impl SuiteTestResult {
475 pub fn gas_used(&self) -> u64 {
477 self.result.kind.report().gas()
478 }
479
480 pub fn contract_name(&self) -> &str {
482 get_contract_name(&self.artifact_id)
483 }
484
485 pub fn file_name(&self) -> &str {
487 get_file_name(&self.artifact_id)
488 }
489}
490
491#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
493pub enum TestStatus {
494 Success,
495 #[default]
496 Failure,
497 Skipped,
498}
499
500impl TestStatus {
501 #[inline]
503 pub const fn is_success(self) -> bool {
504 matches!(self, Self::Success)
505 }
506
507 #[inline]
509 pub const fn is_failure(self) -> bool {
510 matches!(self, Self::Failure)
511 }
512
513 #[inline]
515 pub const fn is_skipped(self) -> bool {
516 matches!(self, Self::Skipped)
517 }
518}
519
520#[derive(Clone, Debug, Serialize, Deserialize)]
523#[serde(tag = "kind", rename_all = "snake_case")]
524pub enum InvariantFailure {
525 Predicate {
527 name: String,
529 reason: String,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
533 counterexample: Option<CounterExample>,
534 persisted_path: std::path::PathBuf,
536 #[serde(default)]
540 is_anchor: bool,
541 },
542 Handler {
544 name: String,
547 reverter: Address,
549 selector: Selector,
551 reason: String,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
555 counterexample: Option<CounterExample>,
556 },
557}
558
559impl InvariantFailure {
560 pub fn reason(&self) -> &str {
562 match self {
563 Self::Predicate { reason, .. } | Self::Handler { reason, .. } => reason,
564 }
565 }
566
567 pub fn name(&self) -> &str {
569 match self {
570 Self::Predicate { name, .. } | Self::Handler { name, .. } => name,
571 }
572 }
573
574 pub fn predicate_name(&self) -> Option<&str> {
576 match self {
577 Self::Predicate { name, .. } => Some(name),
578 Self::Handler { .. } => None,
579 }
580 }
581
582 pub const fn counterexample(&self) -> Option<&CounterExample> {
584 match self {
585 Self::Predicate { counterexample, .. } | Self::Handler { counterexample, .. } => {
586 counterexample.as_ref()
587 }
588 }
589 }
590}
591
592#[derive(Clone, Debug, Serialize, Deserialize)]
594pub struct InvariantPredicateResult {
595 pub name: String,
597 pub status: TestStatus,
599 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub reason: Option<String>,
602}
603
604#[derive(Clone, Debug, Default, Serialize, Deserialize)]
606pub struct TestResult {
607 pub status: TestStatus,
612
613 pub reason: Option<String>,
616
617 #[serde(default, skip_serializing_if = "Vec::is_empty")]
622 pub invariant_failures: Vec<InvariantFailure>,
623
624 #[serde(default, skip_serializing_if = "Vec::is_empty")]
628 pub invariant_predicate_results: Vec<InvariantPredicateResult>,
629
630 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub invariant_failure_dir: Option<std::path::PathBuf>,
634
635 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub invariant_count: Option<usize>,
641
642 #[serde(default, skip_serializing_if = "Vec::is_empty")]
646 pub invariant_handler_failures: Vec<InvariantFailure>,
647
648 pub counterexample: Option<CounterExample>,
650
651 pub logs: Vec<Log>,
654
655 pub decoded_logs: Vec<String>,
658
659 pub kind: TestKind,
661
662 pub traces: Traces,
664
665 #[serde(skip)]
667 pub debug_bytecodes: AddressHashMap<Bytes>,
668
669 #[serde(skip)]
673 pub gas_report_traces: Vec<Vec<CallTraceArena>>,
674
675 #[serde(skip)]
677 pub line_coverage: Option<HitMaps>,
678
679 #[serde(rename = "labeled_addresses")] pub labels: AddressHashMap<String>,
682
683 #[serde(with = "foundry_common::serde_helpers::duration")]
684 pub duration: Duration,
685
686 pub breakpoints: Breakpoints,
688
689 pub gas_snapshots: BTreeMap<String, BTreeMap<String, String>>,
691
692 #[serde(skip)]
694 pub deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>,
695
696 #[serde(skip)]
698 pub symbolic_portfolio_diagnostics: Option<PortfolioDiagnostics>,
699
700 #[serde(skip)]
702 pub symbolic_diagnostics: Option<String>,
703}
704
705impl fmt::Display for TestResult {
706 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
707 f.write_str(&self.render_status_block(false, None))
708 }
709}
710
711impl TestResult {
712 fn render_status_block(
713 &self,
714 user_facing: bool,
715 invariant_campaign_name: Option<&str>,
716 ) -> String {
717 match self.status {
718 TestStatus::Success => {
719 let mut s = String::from("[PASS]");
721 if let Some(CounterExample::Sequence(original, sequence)) = &self.counterexample {
722 s.push_str(
723 format!(
724 "\n\t[Best sequence] (original: {original}, shrunk: {})\n",
725 sequence.len()
726 )
727 .as_str(),
728 );
729 for ex in sequence {
730 writeln!(s, "{ex}").unwrap();
731 }
732 }
733 self.write_invariant_predicate_results(
734 &mut s,
735 user_facing,
736 true,
737 invariant_campaign_name,
738 );
739 format!("{}", s.green().wrap())
740 }
741 TestStatus::Skipped => {
742 let mut s = String::from("[SKIP");
743 if let Some(reason) = &self.reason {
744 write!(s, ": {reason}").unwrap();
745 }
746 s.push(']');
747 self.write_invariant_predicate_results(
748 &mut s,
749 user_facing,
750 true,
751 invariant_campaign_name,
752 );
753 format!("{}", s.yellow())
754 }
755 TestStatus::Failure => {
756 let mut s = String::new();
757 let has_handler_failures = !self.invariant_handler_failures.is_empty();
758 let is_invariant_failure =
759 !self.invariant_failures.is_empty() || has_handler_failures;
760 if !is_invariant_failure {
761 s.push_str("[FAIL");
764 if let Some(reason) = &self.reason {
765 write!(s, ": {reason}").unwrap();
766 }
767 if let Some(counterexample) = &self.counterexample {
768 match counterexample {
769 CounterExample::Single(ex) => {
770 write!(s, "; counterexample: {ex}]").unwrap();
771 }
772 CounterExample::Sequence(original, sequence) => {
773 writeln!(
774 s,
775 "]\n\t[Sequence] (original: {original}, shrunk: {})",
776 sequence.len()
777 )
778 .unwrap();
779 for ex in sequence {
780 writeln!(s, "{ex}").unwrap();
781 }
782 }
783 }
784 } else {
785 s.push(']');
786 }
787 } else if !self.invariant_failures.is_empty() {
788 let multi = self.invariant_failures.len() > 1;
792 let is_campaign = self.invariant_count.is_some();
793 for (i, failure) in self.invariant_failures.iter().enumerate() {
794 if i > 0 {
795 s.push('\n');
796 }
797 let is_anchor =
798 matches!(failure, InvariantFailure::Predicate { is_anchor: true, .. });
799 let name_suffix = if is_campaign || multi || !is_anchor {
800 format!(" {}", failure.name())
801 } else {
802 String::new()
803 };
804 if let Some(CounterExample::Sequence(original, sequence)) =
805 failure.counterexample()
806 {
807 writeln!(
808 s,
809 "[FAIL: {}]{name_suffix}\n\t[Sequence] (original: {original}, shrunk: {})",
810 failure.reason(),
811 sequence.len()
812 )
813 .unwrap();
814 for ex in sequence {
815 writeln!(s, "{ex}").unwrap();
816 }
817 } else {
818 write!(s, "[FAIL: {}]{name_suffix}", failure.reason()).unwrap();
819 }
820 }
821 }
822
823 let rollup_rendered = self.write_invariant_rollup(
824 &mut s,
825 user_facing,
826 is_invariant_failure,
827 invariant_campaign_name,
828 );
829 let show_predicate_header = if user_facing { !rollup_rendered } else { true };
830 self.write_invariant_predicate_results(
831 &mut s,
832 user_facing,
833 show_predicate_header,
834 invariant_campaign_name,
835 );
836 self.write_invariant_persistence_note(&mut s);
837 let handler_preceded = if user_facing {
838 rollup_rendered
839 || self.invariant_predicate_results.len() > 1
840 || !self.invariant_failures.is_empty()
841 } else {
842 !self.invariant_failures.is_empty()
843 || matches!(self.invariant_count, Some(t) if t > 1)
844 };
845 self.write_handler_failures(&mut s, user_facing, handler_preceded);
846
847 format!("{}", s.red().wrap())
848 }
849 }
850 }
851
852 fn write_invariant_rollup(
853 &self,
854 s: &mut String,
855 user_facing: bool,
856 is_invariant_failure: bool,
857 invariant_campaign_name: Option<&str>,
858 ) -> bool {
859 let Some(total) = self.invariant_count else {
860 return false;
861 };
862 if total <= 1 || !is_invariant_failure {
863 return false;
864 }
865
866 writeln!(
867 s,
868 "\n{}: {}/{total} invariants broken",
869 if user_facing {
870 invariant_campaign_name.unwrap_or(INVARIANT_CAMPAIGN_FALLBACK_NAME)
871 } else {
872 "Predicates"
873 },
874 self.invariant_failures.len()
875 )
876 .unwrap();
877 true
878 }
879
880 fn write_invariant_persistence_note(&self, s: &mut String) {
881 if self.invariant_failures.len() > 1
882 && let Some(dir) = &self.invariant_failure_dir
883 {
884 writeln!(
885 s,
886 "{} invariant failure(s) persisted to {} — rerun to shrink",
887 self.invariant_failures.len(),
888 dir.display()
889 )
890 .unwrap();
891 }
892 }
893
894 fn write_handler_failures(&self, s: &mut String, user_facing: bool, preceded: bool) {
895 if self.invariant_handler_failures.is_empty() {
896 return;
897 }
898
899 let prefix = if preceded { "\n" } else { "" };
900 writeln!(
901 s,
902 "{prefix}{}: {} assertion bug(s) found",
903 if user_facing { "Assertion Tests" } else { "Handler assertions" },
904 self.invariant_handler_failures.len()
905 )
906 .unwrap();
907 for failure in &self.invariant_handler_failures {
908 if let Some(CounterExample::Sequence(original, sequence)) = failure.counterexample() {
909 writeln!(
910 s,
911 "[FAIL: {}] {}\n\t[Sequence] (original: {original}, shrunk: {})",
912 failure.reason(),
913 failure.name(),
914 sequence.len()
915 )
916 .unwrap();
917 for ex in sequence {
918 writeln!(s, "{ex}").unwrap();
919 }
920 } else {
921 writeln!(s, "[FAIL: {}] {}", failure.reason(), failure.name()).unwrap();
922 }
923 }
924 }
925
926 fn write_invariant_predicate_results(
928 &self,
929 s: &mut String,
930 user_facing: bool,
931 show_header: bool,
932 invariant_campaign_name: Option<&str>,
933 ) {
934 if self.invariant_predicate_results.len() <= 1 {
935 return;
936 }
937
938 if show_header {
939 s.push('\n');
940 s.push_str(if user_facing {
941 invariant_campaign_name.unwrap_or(INVARIANT_CAMPAIGN_FALLBACK_NAME)
942 } else {
943 "Predicates"
944 });
945 s.push_str(":\n");
946 }
947
948 for predicate in &self.invariant_predicate_results {
949 match predicate.status {
950 TestStatus::Success => {
951 writeln!(s, "[PASS] {}", predicate.name).unwrap();
952 }
953 TestStatus::Failure => {
954 let reason = predicate.reason.as_deref().unwrap_or_default();
955 writeln!(s, "[FAIL: {reason}] {}", predicate.name).unwrap();
956 }
957 TestStatus::Skipped => {
958 if let Some(reason) = &predicate.reason {
959 writeln!(s, "[SKIP: {reason}] {}", predicate.name).unwrap();
960 } else {
961 writeln!(s, "[SKIP] {}", predicate.name).unwrap();
962 }
963 }
964 }
965 }
966 }
967}
968
969macro_rules! extend {
970 ($a:expr, $b:expr, $trace_kind:expr) => {
971 $a.logs.extend($b.logs);
972 $a.labels.extend($b.labels);
973 $a.traces.extend($b.traces.map(|traces| ($trace_kind, traces)));
974 $a.debug_bytecodes.extend($b.debug_bytecodes);
975 $a.merge_coverages($b.line_coverage);
976 };
977}
978
979impl TestResult {
980 pub fn new(setup: &TestSetup) -> Self {
982 Self {
983 labels: setup.labels.clone(),
984 logs: setup.logs.clone(),
985 traces: setup.traces.clone(),
986 debug_bytecodes: setup.debug_bytecodes.clone(),
987 line_coverage: setup.coverage.clone(),
988 ..Default::default()
989 }
990 }
991
992 pub fn fail(reason: String) -> Self {
994 Self { status: TestStatus::Failure, reason: Some(reason), ..Default::default() }
995 }
996
997 pub fn setup_result(setup: TestSetup) -> Self {
999 let TestSetup {
1000 address: _,
1001 fuzz_fixtures: _,
1002 logs,
1003 labels,
1004 traces,
1005 debug_bytecodes,
1006 coverage,
1007 deployed_libs: _,
1008 reason,
1009 skipped,
1010 deployment_failure: _,
1011 } = setup;
1012 Self {
1013 status: if skipped { TestStatus::Skipped } else { TestStatus::Failure },
1014 reason,
1015 logs,
1016 traces,
1017 debug_bytecodes,
1018 line_coverage: coverage,
1019 labels,
1020 ..Default::default()
1021 }
1022 }
1023
1024 pub fn single_skip(&mut self, reason: SkipReason) {
1026 self.status = TestStatus::Skipped;
1027 self.reason = reason.0;
1028 }
1029
1030 pub fn single_fail(&mut self, reason: Option<String>) {
1032 self.status = TestStatus::Failure;
1033 self.reason = reason;
1034 }
1035
1036 pub fn single_result<FEN: FoundryEvmNetwork>(
1039 &mut self,
1040 success: bool,
1041 reason: Option<String>,
1042 raw_call_result: RawCallResult<FEN>,
1043 ) {
1044 self.kind = TestKind::Unit {
1045 gas: raw_call_result.gas_used.saturating_sub(raw_call_result.stipend),
1046 };
1047
1048 extend!(self, raw_call_result, TraceKind::Execution);
1049
1050 self.status = match success {
1051 true => TestStatus::Success,
1052 false => TestStatus::Failure,
1053 };
1054 self.reason = reason;
1055 self.duration = Duration::default();
1056 self.gas_report_traces = Vec::new();
1057
1058 if let Some(cheatcodes) = raw_call_result.cheatcodes {
1059 self.breakpoints = cheatcodes.breakpoints;
1060 self.gas_snapshots = cheatcodes.gas_snapshots;
1061 self.deprecated_cheatcodes = cheatcodes.deprecated;
1062 }
1063 }
1064
1065 pub fn fuzz_result(&mut self, result: FuzzTestResult) {
1068 self.kind = TestKind::Fuzz {
1069 median_gas: result.median_gas(false),
1070 mean_gas: result.mean_gas(false),
1071 first_case: result.first_case,
1072 runs: result.gas_by_case.len(),
1073 failed_corpus_replays: result.failed_corpus_replays,
1074 };
1075
1076 extend!(self, result, TraceKind::Execution);
1078
1079 self.status = if result.skipped {
1080 TestStatus::Skipped
1081 } else if result.success {
1082 TestStatus::Success
1083 } else {
1084 TestStatus::Failure
1085 };
1086 self.reason = result.reason;
1087 self.counterexample = result.counterexample;
1088 self.duration = Duration::default();
1089 self.gas_report_traces = result.gas_report_traces.into_iter().map(|t| vec![t]).collect();
1090 self.breakpoints = result.breakpoints.unwrap_or_default();
1091 self.deprecated_cheatcodes = result.deprecated_cheatcodes;
1092 }
1093
1094 pub fn fuzz_setup_fail(&mut self, e: Report) {
1096 self.kind = TestKind::Fuzz {
1097 first_case: Default::default(),
1098 runs: 0,
1099 mean_gas: 0,
1100 median_gas: 0,
1101 failed_corpus_replays: 0,
1102 };
1103 self.status = TestStatus::Failure;
1104 debug!(?e, "failed to set up fuzz testing environment");
1105 self.reason = Some(format!("failed to set up fuzz testing environment: {e}"));
1106 }
1107
1108 pub fn invariant_skip(&mut self, reason: SkipReason) {
1110 self.invariant_skip_with_predicates(reason, Vec::new());
1111 }
1112
1113 pub fn invariant_skip_with_predicates(
1115 &mut self,
1116 reason: SkipReason,
1117 invariant_predicate_results: Vec<InvariantPredicateResult>,
1118 ) {
1119 self.kind = TestKind::Invariant {
1120 runs: 1,
1121 calls: 1,
1122 reverts: 1,
1123 workers: default_invariant_workers(),
1124 metrics: HashMap::default(),
1125 failed_corpus_replays: 0,
1126 optimization_best_value: None,
1127 };
1128 self.status = TestStatus::Skipped;
1129 let predicate_count = invariant_predicate_results.len();
1130 let is_campaign = predicate_count > 1;
1131 self.reason = if is_campaign { None } else { reason.0 };
1132 self.invariant_count = is_campaign.then_some(predicate_count);
1133 self.invariant_predicate_results = invariant_predicate_results;
1134 }
1135
1136 pub fn invariant_replay_fail(
1138 &mut self,
1139 replayed_entirely: bool,
1140 invariant_name: &String,
1141 replay_reason: Option<String>,
1142 call_sequence: Vec<BaseCounterExample>,
1143 ) {
1144 self.kind = TestKind::Invariant {
1145 runs: 1,
1146 calls: 1,
1147 reverts: 1,
1148 workers: default_invariant_workers(),
1149 metrics: HashMap::default(),
1150 failed_corpus_replays: 0,
1151 optimization_best_value: None,
1152 };
1153 self.status = TestStatus::Failure;
1154 self.reason = replay_reason.or_else(|| {
1155 if replayed_entirely {
1156 Some(format!("{invariant_name} replay failure"))
1157 } else {
1158 Some(format!("{invariant_name} persisted failure revert"))
1159 }
1160 });
1161 self.counterexample = Some(CounterExample::Sequence(call_sequence.len(), call_sequence));
1162 }
1163
1164 pub fn invariant_setup_fail(&mut self, e: Report) {
1166 self.kind = TestKind::Invariant {
1167 runs: 0,
1168 calls: 0,
1169 reverts: 0,
1170 workers: default_invariant_workers(),
1171 metrics: HashMap::default(),
1172 failed_corpus_replays: 0,
1173 optimization_best_value: None,
1174 };
1175 self.status = TestStatus::Failure;
1176 self.reason = Some(format!("failed to set up invariant testing environment: {e}"));
1177 }
1178
1179 #[expect(clippy::too_many_arguments)]
1181 pub fn invariant_result(
1182 &mut self,
1183 gas_report_traces: Vec<Vec<CallTraceArena>>,
1184 success: bool,
1185 invariant_failures: Vec<InvariantFailure>,
1186 invariant_predicate_results: Vec<InvariantPredicateResult>,
1187 invariant_failure_dir: Option<std::path::PathBuf>,
1188 invariant_count: Option<usize>,
1189 invariant_handler_failures: Vec<InvariantFailure>,
1190 counterexample: Option<CounterExample>,
1191 runs: usize,
1192 calls: usize,
1193 reverts: usize,
1194 metrics: Map<String, InvariantMetrics>,
1195 failed_corpus_replays: usize,
1196 workers: usize,
1197 optimization_best_value: Option<I256>,
1198 ) {
1199 self.kind = TestKind::Invariant {
1200 runs,
1201 calls,
1202 reverts,
1203 workers: workers.max(1),
1204 metrics,
1205 failed_corpus_replays,
1206 optimization_best_value,
1207 };
1208 self.status = if optimization_best_value.is_some() || success {
1210 TestStatus::Success
1211 } else {
1212 TestStatus::Failure
1213 };
1214 self.invariant_failures = invariant_failures;
1215 self.invariant_predicate_results = invariant_predicate_results;
1216 self.invariant_failure_dir = invariant_failure_dir;
1217 self.invariant_count = invariant_count;
1218 self.invariant_handler_failures = invariant_handler_failures;
1219 self.counterexample = counterexample;
1223 self.gas_report_traces = gas_report_traces;
1224 }
1225
1226 pub fn table_result(&mut self, result: FuzzTestResult) {
1229 self.kind = TestKind::Table {
1230 median_gas: result.median_gas(false),
1231 mean_gas: result.mean_gas(false),
1232 runs: result.gas_by_case.len(),
1233 };
1234
1235 extend!(self, result, TraceKind::Execution);
1237
1238 self.status = if result.skipped {
1239 TestStatus::Skipped
1240 } else if result.success {
1241 TestStatus::Success
1242 } else {
1243 TestStatus::Failure
1244 };
1245 self.reason = result.reason;
1246 self.counterexample = result.counterexample;
1247 self.duration = Duration::default();
1248 self.gas_report_traces = result.gas_report_traces.into_iter().map(|t| vec![t]).collect();
1249 self.breakpoints = result.breakpoints.unwrap_or_default();
1250 self.deprecated_cheatcodes = result.deprecated_cheatcodes;
1251 }
1252
1253 pub fn symbolic_result(
1255 &mut self,
1256 success: bool,
1257 reason: Option<String>,
1258 counterexample: Option<CounterExample>,
1259 stats: SymbolicStats,
1260 ) {
1261 self.kind = TestKind::Symbolic {
1262 paths: stats.paths,
1263 solver_queries: stats.solver_queries,
1264 smt_queries: stats.smt_queries,
1265 sat_queries: stats.sat_queries,
1266 model_queries: stats.model_queries,
1267 sat_cache_hits: stats.sat_cache_hits,
1268 model_cache_hits: stats.model_cache_hits,
1269 heuristic_witnesses: stats.heuristic_witnesses,
1270 solver_time_ms: stats.solver_time_ms,
1271 };
1272 self.status = if success { TestStatus::Success } else { TestStatus::Failure };
1273 self.reason = reason;
1274 self.counterexample = counterexample;
1275 self.duration = Duration::default();
1276 }
1277
1278 pub fn replay_result(
1280 &mut self,
1281 corpus_entries: usize,
1282 showmap_files: usize,
1283 skipped_entries: usize,
1284 duration: Duration,
1285 ) {
1286 self.kind = TestKind::Replay { corpus_entries, showmap_files, skipped_entries };
1287 self.status = TestStatus::Success;
1288 self.duration = duration;
1289 }
1290
1291 pub fn replay_skip(&mut self, reason: impl Into<String>) {
1293 self.kind = TestKind::Replay { corpus_entries: 0, showmap_files: 0, skipped_entries: 0 };
1294 self.status = TestStatus::Skipped;
1295 self.reason = Some(reason.into());
1296 self.duration = Duration::default();
1297 }
1298
1299 pub const fn is_fuzz(&self) -> bool {
1301 matches!(self.kind, TestKind::Fuzz { .. })
1302 }
1303
1304 pub fn short_result(&self, name: &str) -> String {
1306 self.short_result_with_campaign_name(name, None)
1307 }
1308
1309 pub(crate) fn short_result_with_suite(&self, name: &str, suite_name: &str) -> String {
1310 self.short_result_with_campaign_name(name, Some(get_contract_name(suite_name)))
1311 }
1312
1313 fn short_result_with_campaign_name(&self, name: &str, contract_name: Option<&str>) -> String {
1314 let is_invariant_campaign = self.is_invariant_campaign();
1315 let name = if is_invariant_campaign {
1316 contract_name
1317 .map(invariant_campaign_display_name)
1318 .map(Cow::Owned)
1319 .unwrap_or(Cow::Borrowed(INVARIANT_CAMPAIGN_FALLBACK_NAME))
1320 } else {
1321 Cow::Borrowed(name)
1322 };
1323 let status = self.render_status_block(true, is_invariant_campaign.then_some(name.as_ref()));
1324 format!("{status} {name} {}", self.kind.report())
1325 }
1326
1327 const fn is_invariant_campaign(&self) -> bool {
1328 self.kind.is_invariant() && self.invariant_count.is_some()
1329 }
1330
1331 fn logical_count(&self) -> usize {
1332 let skipped = self.skipped_predicate_count();
1333 if skipped == 0 {
1334 1
1335 } else if self.status.is_skipped() && skipped == self.invariant_predicate_results.len() {
1336 skipped
1337 } else {
1338 1 + skipped
1339 }
1340 }
1341
1342 fn passed_count(&self) -> usize {
1343 usize::from(self.status.is_success())
1344 }
1345
1346 fn skipped_count(&self) -> usize {
1347 let skipped = self.skipped_predicate_count();
1348 if skipped == 0 && self.status.is_skipped() { 1 } else { skipped }
1349 }
1350
1351 fn failed_count(&self) -> usize {
1352 usize::from(self.status.is_failure())
1353 }
1354
1355 fn skipped_predicate_count(&self) -> usize {
1356 self.invariant_predicate_results
1357 .iter()
1358 .filter(|predicate| predicate.status.is_skipped())
1359 .count()
1360 }
1361
1362 pub fn extend<FEN: FoundryEvmNetwork>(&mut self, call_result: RawCallResult<FEN>) {
1364 extend!(self, call_result, TraceKind::Execution);
1365 }
1366
1367 pub fn merge_coverages(&mut self, other_coverage: Option<HitMaps>) {
1369 HitMaps::merge_opt(&mut self.line_coverage, other_coverage);
1370 }
1371}
1372
1373#[derive(Clone, Debug, PartialEq, Eq)]
1375pub enum TestKindReport {
1376 Unit {
1377 gas: u64,
1378 },
1379 Fuzz {
1380 runs: usize,
1381 mean_gas: u64,
1382 median_gas: u64,
1383 failed_corpus_replays: usize,
1384 },
1385 Invariant {
1386 runs: usize,
1387 calls: usize,
1388 reverts: usize,
1389 metrics: Map<String, InvariantMetrics>,
1390 failed_corpus_replays: usize,
1391 optimization_best_value: Option<I256>,
1393 },
1394 Table {
1395 runs: usize,
1396 mean_gas: u64,
1397 median_gas: u64,
1398 },
1399 Symbolic {
1400 paths: usize,
1401 solver_queries: usize,
1402 smt_queries: usize,
1403 sat_queries: usize,
1404 model_queries: usize,
1405 sat_cache_hits: usize,
1406 model_cache_hits: usize,
1407 heuristic_witnesses: usize,
1408 solver_time_ms: u64,
1409 },
1410 Replay {
1412 corpus_entries: usize,
1413 showmap_files: usize,
1414 skipped_entries: usize,
1415 },
1416}
1417
1418impl fmt::Display for TestKindReport {
1419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1420 match self {
1421 Self::Unit { gas } => {
1422 write!(f, "(gas: {gas})")
1423 }
1424 Self::Fuzz { runs, mean_gas, median_gas, failed_corpus_replays } => {
1425 if *failed_corpus_replays != 0 {
1426 write!(
1427 f,
1428 "(runs: {runs}, μ: {mean_gas}, ~: {median_gas}, failed corpus replays: {failed_corpus_replays})"
1429 )
1430 } else {
1431 write!(f, "(runs: {runs}, μ: {mean_gas}, ~: {median_gas})")
1432 }
1433 }
1434 Self::Invariant {
1435 runs,
1436 calls,
1437 reverts,
1438 metrics: _,
1439 failed_corpus_replays,
1440 optimization_best_value,
1441 } => {
1442 if let Some(best_value) = optimization_best_value {
1444 write!(f, "(best: {best_value}, runs: {runs}, calls: {calls})")
1445 } else if *failed_corpus_replays != 0 {
1446 write!(
1447 f,
1448 "(runs: {runs}, calls: {calls}, reverts: {reverts}, failed corpus replays: {failed_corpus_replays})"
1449 )
1450 } else {
1451 write!(f, "(runs: {runs}, calls: {calls}, reverts: {reverts})")
1452 }
1453 }
1454 Self::Table { runs, mean_gas, median_gas } => {
1455 write!(f, "(runs: {runs}, μ: {mean_gas}, ~: {median_gas})")
1456 }
1457 Self::Symbolic {
1458 paths,
1459 solver_queries,
1460 smt_queries,
1461 sat_queries,
1462 model_queries,
1463 sat_cache_hits,
1464 model_cache_hits,
1465 heuristic_witnesses,
1466 solver_time_ms,
1467 } => {
1468 write!(
1469 f,
1470 "(paths: {paths}, queries: {solver_queries}, smt: {smt_queries}, sat: {sat_queries} ({sat_cache_hits} cached), models: {model_queries} ({model_cache_hits} cached), hard-arith: {heuristic_witnesses}, solver: {solver_time_ms}ms)"
1471 )
1472 }
1473 Self::Replay { corpus_entries, showmap_files, skipped_entries } => {
1474 if *skipped_entries != 0 {
1475 write!(
1476 f,
1477 "(replay: {corpus_entries} entries, {showmap_files} files, {skipped_entries} skipped)"
1478 )
1479 } else {
1480 write!(f, "(replay: {corpus_entries} entries, {showmap_files} files)")
1481 }
1482 }
1483 }
1484 }
1485}
1486
1487impl TestKindReport {
1488 pub const fn gas(&self) -> u64 {
1490 match *self {
1491 Self::Unit { gas } => gas,
1492 Self::Fuzz { median_gas, .. } | Self::Table { median_gas, .. } => median_gas,
1494 Self::Invariant { .. } | Self::Symbolic { .. } | Self::Replay { .. } => 0,
1496 }
1497 }
1498}
1499
1500#[derive(Clone, Debug, Serialize, Deserialize)]
1502pub enum TestKind {
1503 Unit { gas: u64 },
1505 Fuzz {
1507 first_case: FuzzCase,
1509 runs: usize,
1510 mean_gas: u64,
1511 median_gas: u64,
1512 failed_corpus_replays: usize,
1513 },
1514 Invariant {
1516 runs: usize,
1517 calls: usize,
1518 reverts: usize,
1519 #[serde(default = "default_invariant_workers")]
1521 workers: usize,
1522 metrics: Map<String, InvariantMetrics>,
1523 failed_corpus_replays: usize,
1524 optimization_best_value: Option<I256>,
1526 },
1527 Table { runs: usize, mean_gas: u64, median_gas: u64 },
1529 Symbolic {
1531 paths: usize,
1532 solver_queries: usize,
1533 #[serde(default)]
1534 smt_queries: usize,
1535 #[serde(default)]
1536 sat_queries: usize,
1537 #[serde(default)]
1538 model_queries: usize,
1539 #[serde(default)]
1540 sat_cache_hits: usize,
1541 #[serde(default)]
1542 model_cache_hits: usize,
1543 #[serde(default)]
1544 heuristic_witnesses: usize,
1545 #[serde(default)]
1546 solver_time_ms: u64,
1547 },
1548 Replay { corpus_entries: usize, showmap_files: usize, skipped_entries: usize },
1550}
1551
1552impl Default for TestKind {
1553 fn default() -> Self {
1554 Self::Unit { gas: 0 }
1555 }
1556}
1557
1558impl TestKind {
1559 pub const fn is_fuzz(&self) -> bool {
1561 matches!(self, Self::Fuzz { .. })
1562 }
1563
1564 pub const fn is_invariant(&self) -> bool {
1566 matches!(self, Self::Invariant { .. })
1567 }
1568
1569 pub const fn invariant_workers(&self) -> Option<usize> {
1571 match self {
1572 Self::Invariant { workers, .. } => Some(*workers),
1573 _ => None,
1574 }
1575 }
1576
1577 pub fn report(&self) -> TestKindReport {
1579 match self {
1580 Self::Unit { gas } => TestKindReport::Unit { gas: *gas },
1581 Self::Fuzz { first_case: _, runs, mean_gas, median_gas, failed_corpus_replays } => {
1582 TestKindReport::Fuzz {
1583 runs: *runs,
1584 mean_gas: *mean_gas,
1585 median_gas: *median_gas,
1586 failed_corpus_replays: *failed_corpus_replays,
1587 }
1588 }
1589 Self::Invariant {
1590 runs,
1591 calls,
1592 reverts,
1593 workers: _,
1594 metrics: _,
1595 failed_corpus_replays,
1596 optimization_best_value,
1597 } => TestKindReport::Invariant {
1598 runs: *runs,
1599 calls: *calls,
1600 reverts: *reverts,
1601 metrics: HashMap::default(),
1602 failed_corpus_replays: *failed_corpus_replays,
1603 optimization_best_value: *optimization_best_value,
1604 },
1605 Self::Table { runs, mean_gas, median_gas } => {
1606 TestKindReport::Table { runs: *runs, mean_gas: *mean_gas, median_gas: *median_gas }
1607 }
1608 Self::Symbolic {
1609 paths,
1610 solver_queries,
1611 smt_queries,
1612 sat_queries,
1613 model_queries,
1614 sat_cache_hits,
1615 model_cache_hits,
1616 heuristic_witnesses,
1617 solver_time_ms,
1618 } => TestKindReport::Symbolic {
1619 paths: *paths,
1620 solver_queries: *solver_queries,
1621 smt_queries: *smt_queries,
1622 sat_queries: *sat_queries,
1623 model_queries: *model_queries,
1624 sat_cache_hits: *sat_cache_hits,
1625 model_cache_hits: *model_cache_hits,
1626 heuristic_witnesses: *heuristic_witnesses,
1627 solver_time_ms: *solver_time_ms,
1628 },
1629 Self::Replay { corpus_entries, showmap_files, skipped_entries } => {
1630 TestKindReport::Replay {
1631 corpus_entries: *corpus_entries,
1632 showmap_files: *showmap_files,
1633 skipped_entries: *skipped_entries,
1634 }
1635 }
1636 }
1637 }
1638}
1639
1640const fn default_invariant_workers() -> usize {
1641 1
1642}
1643
1644#[derive(Clone, Debug, Default)]
1649pub struct TestSetup {
1650 pub address: Address,
1652 pub fuzz_fixtures: FuzzFixtures,
1654
1655 pub logs: Vec<Log>,
1657 pub labels: AddressHashMap<String>,
1659 pub traces: Traces,
1661 pub debug_bytecodes: AddressHashMap<Bytes>,
1663 pub coverage: Option<HitMaps>,
1665 pub deployed_libs: Vec<Address>,
1667
1668 pub reason: Option<String>,
1670 pub skipped: bool,
1672 pub deployment_failure: bool,
1674}
1675
1676impl TestSetup {
1677 pub fn failed(reason: String) -> Self {
1678 Self { reason: Some(reason), ..Default::default() }
1679 }
1680
1681 pub fn skipped(reason: String) -> Self {
1682 Self { reason: Some(reason), skipped: true, ..Default::default() }
1683 }
1684
1685 pub fn extend<FEN: FoundryEvmNetwork>(
1686 &mut self,
1687 raw: RawCallResult<FEN>,
1688 trace_kind: TraceKind,
1689 ) {
1690 extend!(self, raw, trace_kind);
1691 }
1692
1693 pub fn merge_coverages(&mut self, other_coverage: Option<HitMaps>) {
1694 HitMaps::merge_opt(&mut self.coverage, other_coverage);
1695 }
1696}