forge/cmd/test/
summary.rs

1use crate::cmd::test::TestOutcome;
2use comfy_table::{
3    Cell, Color, Row, Table, modifiers::UTF8_ROUND_CORNERS, presets::ASCII_MARKDOWN,
4};
5use foundry_common::{
6    reports::{ReportKind, report_kind},
7    shell,
8};
9use foundry_evm::executors::invariant::InvariantMetrics;
10use itertools::Itertools;
11use serde_json::json;
12use std::{collections::HashMap, fmt::Display};
13
14/// Represents a test summary report.
15pub struct TestSummaryReport {
16    /// The kind of report to generate.
17    report_kind: ReportKind,
18    /// Whether the report should be detailed.
19    is_detailed: bool,
20    /// The test outcome to report.
21    outcome: TestOutcome,
22}
23
24impl TestSummaryReport {
25    pub fn new(is_detailed: bool, outcome: TestOutcome) -> Self {
26        Self { report_kind: report_kind(), is_detailed, outcome }
27    }
28}
29
30impl Display for TestSummaryReport {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
32        match self.report_kind {
33            ReportKind::Text => {
34                writeln!(
35                    f,
36                    "\n{}",
37                    &self.format_table_output(&self.is_detailed, &self.outcome, false)
38                )?;
39            }
40            ReportKind::JSON => {
41                writeln!(f, "{}", &self.format_json_output(&self.is_detailed, &self.outcome))?;
42            }
43            ReportKind::Markdown => {
44                writeln!(
45                    f,
46                    "\n{}",
47                    &self.format_table_output(&self.is_detailed, &self.outcome, true)
48                )?;
49            }
50        }
51
52        Ok(())
53    }
54}
55
56impl TestSummaryReport {
57    // Helper function to format the JSON output.
58    fn format_json_output(&self, is_detailed: &bool, outcome: &TestOutcome) -> String {
59        let output = json!({
60            "results": outcome.results.iter().map(|(contract, suite)| {
61                let (suite_path, suite_name) = contract.split_once(':').unwrap();
62                let passed = suite.successes().count();
63                let failed = suite.failures().count();
64                let skipped = suite.skips().count();
65                let mut result = json!({
66                    "suite": suite_name,
67                    "passed": passed,
68                    "failed": failed,
69                    "skipped": skipped,
70                });
71
72                if *is_detailed {
73                    result["file_path"] = serde_json::Value::String(suite_path.to_string());
74                    result["duration"] = serde_json::Value::String(format!("{:.2?}", suite.duration));
75                }
76
77                result
78            }).collect::<Vec<serde_json::Value>>(),
79        });
80
81        serde_json::to_string_pretty(&output).unwrap()
82    }
83
84    fn format_table_output(&self, is_detailed: &bool, outcome: &TestOutcome, md: bool) -> Table {
85        let mut table = Table::new();
86        if md {
87            table.load_preset(ASCII_MARKDOWN);
88        } else {
89            table.apply_modifier(UTF8_ROUND_CORNERS);
90        }
91
92        let mut row = Row::from(vec![
93            Cell::new("Test Suite"),
94            Cell::new("Passed").fg(Color::Green),
95            Cell::new("Failed").fg(Color::Red),
96            Cell::new("Skipped").fg(Color::Yellow),
97        ]);
98        if *is_detailed {
99            row.add_cell(Cell::new("File Path").fg(Color::Cyan));
100            row.add_cell(Cell::new("Duration").fg(Color::Cyan));
101        }
102        table.set_header(row);
103
104        // Traverse the test_results vector and build the table
105        for (contract, suite) in &outcome.results {
106            let mut row = Row::new();
107            let (suite_path, suite_name) = contract.split_once(':').unwrap();
108
109            let passed = suite.successes().count();
110            let mut passed_cell = Cell::new(passed);
111
112            let failed = suite.failures().count();
113            let mut failed_cell = Cell::new(failed);
114
115            let skipped = suite.skips().count();
116            let mut skipped_cell = Cell::new(skipped);
117
118            row.add_cell(Cell::new(suite_name));
119
120            if passed > 0 {
121                passed_cell = passed_cell.fg(Color::Green);
122            }
123            row.add_cell(passed_cell);
124
125            if failed > 0 {
126                failed_cell = failed_cell.fg(Color::Red);
127            }
128            row.add_cell(failed_cell);
129
130            if skipped > 0 {
131                skipped_cell = skipped_cell.fg(Color::Yellow);
132            }
133            row.add_cell(skipped_cell);
134
135            if self.is_detailed {
136                row.add_cell(Cell::new(suite_path));
137                row.add_cell(Cell::new(format!("{:.2?}", suite.duration).to_string()));
138            }
139
140            table.add_row(row);
141        }
142
143        table
144    }
145}
146
147/// Helper function to create the invariant metrics table.
148///
149/// ╭-----------------------+----------------+-------+---------+----------╮
150/// | Contract              | Selector       | Calls | Reverts | Discards |
151/// +=====================================================================+
152/// | AnotherCounterHandler | doWork         | 7451  | 123     | 4941     |
153/// |-----------------------+----------------+-------+---------+----------|
154/// | AnotherCounterHandler | doWorkThing    | 7279  | 137     | 4849     |
155/// |-----------------------+----------------+-------+---------+----------|
156/// | CounterHandler        | doAnotherThing | 7302  | 150     | 4794     |
157/// |-----------------------+----------------+-------+---------+----------|
158/// | CounterHandler        | doSomething    | 7382  | 160     |4794      |
159/// ╰-----------------------+----------------+-------+---------+----------╯
160pub(crate) fn format_invariant_metrics_table(
161    test_metrics: &HashMap<String, InvariantMetrics>,
162) -> Table {
163    let mut table = Table::new();
164    if shell::is_markdown() {
165        table.load_preset(ASCII_MARKDOWN);
166    } else {
167        table.apply_modifier(UTF8_ROUND_CORNERS);
168    }
169
170    table.set_header(vec![
171        Cell::new("Contract"),
172        Cell::new("Selector"),
173        Cell::new("Calls").fg(Color::Green),
174        Cell::new("Reverts").fg(Color::Red),
175        Cell::new("Discards").fg(Color::Yellow),
176    ]);
177
178    for name in test_metrics.keys().sorted() {
179        if let Some((contract, selector)) =
180            name.split_once(':').map_or(name.as_str(), |(_, contract)| contract).split_once('.')
181        {
182            let mut row = Row::new();
183            row.add_cell(Cell::new(contract));
184            row.add_cell(Cell::new(selector));
185
186            if let Some(metrics) = test_metrics.get(name) {
187                let calls_cell = Cell::new(metrics.calls).fg(if metrics.calls > 0 {
188                    Color::Green
189                } else {
190                    Color::White
191                });
192
193                let reverts_cell = Cell::new(metrics.reverts).fg(if metrics.reverts > 0 {
194                    Color::Red
195                } else {
196                    Color::White
197                });
198
199                let discards_cell = Cell::new(metrics.discards).fg(if metrics.discards > 0 {
200                    Color::Yellow
201                } else {
202                    Color::White
203                });
204
205                row.add_cell(calls_cell);
206                row.add_cell(reverts_cell);
207                row.add_cell(discards_cell);
208            }
209
210            table.add_row(row);
211        }
212    }
213    table
214}
215
216#[cfg(test)]
217mod tests {
218    use crate::cmd::test::summary::format_invariant_metrics_table;
219    use foundry_evm::executors::invariant::InvariantMetrics;
220    use std::collections::HashMap;
221
222    #[test]
223    fn test_invariant_metrics_table() {
224        let mut test_metrics = HashMap::new();
225        test_metrics.insert(
226            "SystemConfig.setGasLimit".to_string(),
227            InvariantMetrics { calls: 10, reverts: 1, discards: 1 },
228        );
229        test_metrics.insert(
230            "src/universal/Proxy.sol:Proxy.changeAdmin".to_string(),
231            InvariantMetrics { calls: 20, reverts: 2, discards: 2 },
232        );
233        let table = format_invariant_metrics_table(&test_metrics);
234        assert_eq!(table.row_count(), 2);
235
236        let mut first_row_content = table.row(0).unwrap().cell_iter();
237        assert_eq!(first_row_content.next().unwrap().content(), "SystemConfig");
238        assert_eq!(first_row_content.next().unwrap().content(), "setGasLimit");
239        assert_eq!(first_row_content.next().unwrap().content(), "10");
240        assert_eq!(first_row_content.next().unwrap().content(), "1");
241        assert_eq!(first_row_content.next().unwrap().content(), "1");
242
243        let mut second_row_content = table.row(1).unwrap().cell_iter();
244        assert_eq!(second_row_content.next().unwrap().content(), "Proxy");
245        assert_eq!(second_row_content.next().unwrap().content(), "changeAdmin");
246        assert_eq!(second_row_content.next().unwrap().content(), "20");
247        assert_eq!(second_row_content.next().unwrap().content(), "2");
248        assert_eq!(second_row_content.next().unwrap().content(), "2");
249    }
250}