Skip to main content

forge/cmd/test/
summary.rs

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