diff --git a/crates/forge/bin/cmd/test/mod.rs b/crates/forge/bin/cmd/test/mod.rs index 3c6b03019e283..8570509875f85 100644 --- a/crates/forge/bin/cmd/test/mod.rs +++ b/crates/forge/bin/cmd/test/mod.rs @@ -1,6 +1,7 @@ use super::{install, test::filter::ProjectPathsAwareFilter, watch::WatchArgs}; use alloy_primitives::U256; use clap::Parser; + use eyre::Result; use forge::{ decode::decode_console_logs, @@ -40,6 +41,9 @@ use watchexec::config::{InitConfig, RuntimeConfig}; use yansi::Paint; mod filter; +mod summary; +use summary::TestSummaryReporter; + pub use filter::FilterArgs; // Loads project's figment and merges the build cli arguments into it @@ -109,6 +113,14 @@ pub struct TestArgs { #[clap(flatten)] pub watch: WatchArgs, + + /// Print test summary table + #[clap(long, help_heading = "Display options")] + pub summary: bool, + + /// Print detailed test summary table + #[clap(long, help_heading = "Display options")] + pub detailed: bool, } impl TestArgs { @@ -338,6 +350,11 @@ impl TestArgs { } else if self.list { list(runner, filter, self.json) } else { + if self.detailed && !self.summary { + return Err(eyre::eyre!( + "Missing `--summary` option in your command. You must pass it along with the `--detailed` option to view detailed test summary." + )); + } test( config, runner, @@ -348,6 +365,8 @@ impl TestArgs { test_options, self.gas_report, self.fail_fast, + self.summary, + self.detailed, ) .await } @@ -614,8 +633,11 @@ async fn test( test_options: TestOptions, gas_reporting: bool, fail_fast: bool, + summary: bool, + detailed: bool, ) -> Result { trace!(target: "forge::test", "running all tests"); + if runner.matching_test_function_count(&filter) == 0 { let filter_str = filter.to_string(); if filter_str.is_empty() { @@ -663,6 +685,7 @@ async fn test( let mut total_passed = 0; let mut total_failed = 0; let mut total_skipped = 0; + let mut suite_results: Vec = Vec::new(); 'outer: for (contract_name, suite_result) in rx { results.insert(contract_name.clone(), suite_result.clone()); @@ -754,13 +777,18 @@ async fn test( gas_report.analyze(&result.traces); } } - let block_outcome = TestOutcome::new([(contract_name, suite_result)].into(), allow_failure); + let block_outcome = + TestOutcome::new([(contract_name.clone(), suite_result)].into(), allow_failure); total_passed += block_outcome.successes().count(); total_failed += block_outcome.failures().count(); total_skipped += block_outcome.skips().count(); println!("{}", block_outcome.summary()); + + if summary { + suite_results.push(block_outcome.clone()); + } } if gas_reporting { @@ -774,12 +802,19 @@ async fn test( "{}", format_aggregated_summary(num_test_suites, total_passed, total_failed, total_skipped) ); + + if summary { + let mut summary_table = TestSummaryReporter::new(detailed); + println!("\n\nTest Summary:"); + summary_table.print_summary(suite_results); + } } // reattach the thread let _results = handle.await?; trace!(target: "forge::test", "received {} results", results.len()); + Ok(TestOutcome::new(results, allow_failure)) } diff --git a/crates/forge/bin/cmd/test/summary.rs b/crates/forge/bin/cmd/test/summary.rs new file mode 100644 index 0000000000000..5f8bd9650bd8b --- /dev/null +++ b/crates/forge/bin/cmd/test/summary.rs @@ -0,0 +1,111 @@ +use crate::cmd::test::TestOutcome; +use comfy_table::{ + modifiers::UTF8_ROUND_CORNERS, Attribute, Cell, CellAlignment, Color, Row, Table, +}; + +/// A simple summary reporter that prints the test results in a table. +pub struct TestSummaryReporter { + /// The test summary table. + pub(crate) table: Table, + pub(crate) is_detailed: bool, +} + +impl TestSummaryReporter { + pub(crate) fn new(is_detailed: bool) -> Self { + let mut table = Table::new(); + table.apply_modifier(UTF8_ROUND_CORNERS); + let mut row = Row::from(vec![ + Cell::new("Test Suite") + .set_alignment(CellAlignment::Center) + .add_attribute(Attribute::Bold), + Cell::new("Passed") + .set_alignment(CellAlignment::Center) + .add_attribute(Attribute::Bold) + .fg(Color::Green), + Cell::new("Failed") + .set_alignment(CellAlignment::Center) + .add_attribute(Attribute::Bold) + .fg(Color::Red), + Cell::new("Skipped") + .set_alignment(CellAlignment::Center) + .add_attribute(Attribute::Bold) + .fg(Color::Yellow), + ]); + if is_detailed { + row.add_cell( + Cell::new("File Path") + .set_alignment(CellAlignment::Center) + .add_attribute(Attribute::Bold), + ); + row.add_cell( + Cell::new("Duration") + .set_alignment(CellAlignment::Center) + .add_attribute(Attribute::Bold), + ); + } + table.set_header(row); + + Self { table, is_detailed } + } + + pub(crate) fn print_summary(&mut self, mut test_results: Vec) { + // Sort by suite name first + + // Using `sort_by_cached_key` so that the key extraction logic runs only once + test_results.sort_by_cached_key(|test_outcome| { + test_outcome + .results + .keys() + .next() + .and_then(|suite| suite.split(':').nth(1)) + .unwrap() + .to_string() + }); + + // Traverse the test_results vector and build the table + for suite in &test_results { + for contract in suite.results.keys() { + let mut row = Row::new(); + let suite_name = contract.split(':').nth(1).unwrap(); + let suite_path = contract.split(':').nth(0).unwrap(); + + let passed = suite.successes().count(); + let mut passed_cell = Cell::new(passed).set_alignment(CellAlignment::Center); + + let failed = suite.failures().count(); + let mut failed_cell = Cell::new(failed).set_alignment(CellAlignment::Center); + + let skipped = suite.skips().count(); + let mut skipped_cell = Cell::new(skipped).set_alignment(CellAlignment::Center); + + let duration = suite.duration(); + + row.add_cell(Cell::new(suite_name)); + + if passed > 0 { + passed_cell = passed_cell.fg(Color::Green); + } + row.add_cell(passed_cell); + + if failed > 0 { + failed_cell = failed_cell.fg(Color::Red); + } + row.add_cell(failed_cell); + + if skipped > 0 { + skipped_cell = skipped_cell.fg(Color::Yellow); + } + row.add_cell(skipped_cell); + + if self.is_detailed { + row.add_cell(Cell::new(suite_path)); + row.add_cell(Cell::new(format!("{:.2?}", duration).to_string())); + } + + self.table.add_row(row); + } + } + // Print the summary table + println!("\n{}", self.table); + } +}