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