Skip to main content

forest/tool/subcommands/api_cmd/
report.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4// RPC comparison test harness (not `#[cfg(test)]`, but test tooling rather than node runtime).
5#![allow(clippy::unwrap_used)]
6
7use super::ReportMode;
8use crate::prelude::*;
9use crate::rpc::{self, ApiPaths, FilterList, Permission};
10use crate::tool::subcommands::api_cmd::api_compare_tests::TestSummary;
11use ahash::{HashMap, HashSet};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use serde_with::{DisplayFromStr, DurationMilliSeconds, DurationSeconds, serde_as};
15use similar::{ChangeTag, TextDiff};
16use std::collections::BTreeSet;
17use std::path::Path;
18use std::time::{Duration, Instant};
19use tabled::{builder::Builder, settings::Style};
20
21/// Tracks the performance metrics for a single RPC method.
22#[serde_as]
23#[derive(Debug, Clone, Serialize, Deserialize)]
24struct PerformanceMetrics {
25    #[serde_as(as = "DurationMilliSeconds<u64>")]
26    total_duration_ms: Duration,
27
28    #[serde_as(as = "DurationMilliSeconds<u64>")]
29    average_duration_ms: Duration,
30
31    #[serde_as(as = "DurationMilliSeconds<u64>")]
32    min_duration_ms: Duration,
33
34    #[serde_as(as = "DurationMilliSeconds<u64>")]
35    max_duration_ms: Duration,
36    test_count: usize,
37}
38
39impl PerformanceMetrics {
40    pub fn from_durations(durations: &[Duration]) -> Option<Self> {
41        if durations.is_empty() {
42            return None;
43        }
44
45        let test_count = durations.len();
46        let total_duration_ms: Duration = durations.iter().sum();
47        let average_duration_ms = total_duration_ms / test_count as u32;
48
49        Some(Self {
50            total_duration_ms,
51            average_duration_ms,
52            min_duration_ms: *durations.iter().min().expect("durations is not empty"),
53            max_duration_ms: *durations.iter().max().expect("durations is not empty"),
54            test_count,
55        })
56    }
57}
58
59/// Details about a successful test instance
60#[serde_as]
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63struct SuccessfulTest {
64    /// The parameters used for this test
65    request_params: serde_json::Value,
66
67    /// Forest node response
68    forest_status: TestSummary,
69
70    /// Lotus node response
71    lotus_status: TestSummary,
72
73    /// Individual test execution duration in milliseconds
74    #[serde_as(as = "DurationMilliSeconds<u64>")]
75    execution_duration_ms: Duration,
76}
77
78/// Testing status for a method
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case", tag = "type")]
81enum MethodTestStatus {
82    /// Method was tested
83    Tested {
84        total_count: usize,
85        success_count: usize,
86        failure_count: usize,
87    },
88    /// Method was filtered out by configuration
89    Filtered,
90    /// Method exists but was not tested
91    NotTested,
92}
93
94/// Details about a failed test instance
95#[serde_as]
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98struct FailedTest {
99    /// The parameters used for this test
100    pub request_params: serde_json::Value,
101
102    /// Forest test result
103    pub forest_status: TestSummary,
104
105    /// Lotus node result
106    pub lotus_status: TestSummary,
107
108    /// Diff between Forest and Lotus responses
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub response_diff: Option<String>,
111
112    /// Individual test execution duration in milliseconds
113    #[serde_as(as = "DurationMilliSeconds<u64>")]
114    pub execution_duration_ms: Duration,
115}
116
117/// Detailed report for a single RPC method
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120struct MethodReport {
121    /// Full RPC method name
122    name: String,
123
124    /// Required permission level
125    permission: Permission,
126
127    /// Current testing status
128    status: MethodTestStatus,
129
130    /// API versions this method was exercised under
131    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
132    api_versions: BTreeSet<ApiPaths>,
133
134    // Performance metrics (always included)
135    #[serde(skip_serializing_if = "Option::is_none")]
136    performance: Option<PerformanceMetrics>,
137
138    /// Details of successful test instances (only in full mode)
139    #[serde(skip_serializing_if = "Vec::is_empty")]
140    success_test_params: Vec<SuccessfulTest>,
141
142    /// Details of failed test instances
143    #[serde(skip_serializing_if = "Vec::is_empty")]
144    failed_test_params: Vec<FailedTest>,
145}
146
147/// Report of all API comparison test results
148#[serde_as]
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub struct ApiTestReport {
152    /// timestamp of when the test execution started
153    #[serde_as(as = "DisplayFromStr")]
154    execution_datetime_utc: DateTime<Utc>,
155
156    /// Total duration of the test run in seconds
157    #[serde_as(as = "DurationSeconds<u64>")]
158    total_duration_secs: Duration,
159
160    /// Comprehensive report for each RPC method
161    methods: Vec<MethodReport>,
162}
163
164/// Report builder to encapsulate report generation logic
165pub struct ReportBuilder {
166    method_reports: HashMap<String, MethodReport>,
167    method_timings: HashMap<String, Vec<Duration>>,
168    report_mode: ReportMode,
169    start_time: Instant,
170    failed_test_dumps: Vec<super::api_compare_tests::TestDump>,
171}
172
173impl ReportBuilder {
174    pub fn new(filter_list: &FilterList, report_mode: ReportMode) -> Self {
175        let all_methods = rpc::collect_rpc_method_info();
176
177        let method_reports = all_methods
178            .into_iter()
179            .map(|(method_name, permission)| {
180                let report = MethodReport {
181                    name: method_name.to_string(),
182                    permission,
183                    status: if !filter_list.authorize(method_name) {
184                        MethodTestStatus::Filtered
185                    } else {
186                        MethodTestStatus::NotTested
187                    },
188                    api_versions: BTreeSet::new(),
189                    performance: None,
190                    success_test_params: vec![],
191                    failed_test_params: vec![],
192                };
193                (method_name.to_string(), report)
194            })
195            .collect();
196
197        Self {
198            method_reports,
199            method_timings: HashMap::new(),
200            report_mode,
201            start_time: Instant::now(),
202            failed_test_dumps: vec![],
203        }
204    }
205
206    pub fn track_test_result(
207        &mut self,
208        method_name: &str,
209        success: bool,
210        test_result: &super::api_compare_tests::TestResult,
211        test_params: &serde_json::Value,
212        api_path: ApiPaths,
213    ) {
214        if let Some(report) = self.method_reports.get_mut(method_name) {
215            report.api_versions.insert(api_path);
216
217            // Update test status
218            match &mut report.status {
219                MethodTestStatus::NotTested | MethodTestStatus::Filtered => {
220                    report.status = MethodTestStatus::Tested {
221                        total_count: 1,
222                        success_count: if success { 1 } else { 0 },
223                        failure_count: if success { 0 } else { 1 },
224                    };
225                }
226                MethodTestStatus::Tested {
227                    total_count,
228                    success_count,
229                    failure_count,
230                    ..
231                } => {
232                    *total_count += 1;
233                    if success {
234                        *success_count += 1;
235                    } else {
236                        *failure_count += 1;
237                    }
238                }
239            }
240
241            // Track timing
242            self.method_timings
243                .entry(method_name.to_string())
244                .or_default()
245                .push(test_result.duration);
246
247            // if there is no test result for the current method, we can skip this test
248            if test_result.test_dump.is_none() {
249                return;
250            }
251
252            let test_dump = test_result.test_dump.as_ref().unwrap();
253
254            if !success {
255                self.failed_test_dumps.push(test_dump.clone());
256            }
257
258            // Add test details based on mode and success
259            if success && matches!(self.report_mode, ReportMode::Full) {
260                if let (Ok(_), Ok(_)) = (&test_dump.forest_response, &test_dump.lotus_response) {
261                    report.success_test_params.push(SuccessfulTest {
262                        request_params: test_params.clone(),
263                        forest_status: test_result.forest_status.clone(),
264                        lotus_status: test_result.lotus_status.clone(),
265                        execution_duration_ms: test_result.duration,
266                    });
267                }
268            } else if !success
269                && matches!(self.report_mode, ReportMode::Full | ReportMode::FailureOnly)
270            {
271                let response_diff = match (&test_dump.forest_response, &test_dump.lotus_response) {
272                    (Ok(forest_json), Ok(lotus_json)) => {
273                        Some(generate_diff(forest_json, lotus_json))
274                    }
275                    _ => None,
276                };
277
278                report.failed_test_params.push(FailedTest {
279                    request_params: test_params.clone(),
280                    forest_status: test_result.forest_status.clone(),
281                    lotus_status: test_result.lotus_status.clone(),
282                    response_diff,
283                    execution_duration_ms: test_result.duration,
284                });
285            }
286        }
287    }
288
289    /// Check if there were any failures
290    pub fn has_failures(&self) -> bool {
291        self.method_reports.values().any(|report| {
292            matches!(
293                report.status,
294                MethodTestStatus::Tested { failure_count, .. } if failure_count > 0
295            )
296        })
297    }
298
299    /// Print a summary of test results
300    pub fn print_summary(&mut self) {
301        // Calculate performance metrics for each method before printing
302        for (method_name, timings) in &self.method_timings {
303            if let Some(report) = self.method_reports.get_mut(method_name) {
304                report.performance = PerformanceMetrics::from_durations(timings);
305            }
306        }
307
308        let mut builder = Builder::default();
309        builder.push_record(["RPC Method", "Forest", "Lotus", "API Versions", "Status"]);
310
311        let mut methods: Vec<&MethodReport> = self.method_reports.values().collect();
312        methods.sort_by(|a, b| a.name.cmp(&b.name));
313
314        for report in methods {
315            match &report.status {
316                MethodTestStatus::Tested {
317                    total_count,
318                    success_count,
319                    failure_count,
320                } => {
321                    let method_name = if *total_count > 1 {
322                        format!("{} ({})", report.name, total_count)
323                    } else {
324                        report.name.clone()
325                    };
326
327                    let status = if *failure_count == 0 {
328                        "āœ… All Passed".into()
329                    } else {
330                        let mut reasons = HashSet::new();
331                        for failure in &report.failed_test_params {
332                            if failure.forest_status != TestSummary::Valid {
333                                reasons.insert(failure.forest_status.to_string());
334                            }
335                            if failure.lotus_status != TestSummary::Valid {
336                                reasons.insert(failure.lotus_status.to_string());
337                            }
338                        }
339
340                        let reasons_str =
341                            reasons.iter().map(|s| s.as_str()).collect_vec().join(", ");
342
343                        if *success_count == 0 {
344                            format!("āŒ All Failed ({reasons_str})")
345                        } else {
346                            format!("āš ļø  Mixed Results ({reasons_str})")
347                        }
348                    };
349
350                    builder.push_record([
351                        method_name.as_str(),
352                        &format!("{success_count}/{total_count}"),
353                        &format!("{success_count}/{total_count}"),
354                        &report.api_versions.iter().join(", "),
355                        &status,
356                    ]);
357                }
358                MethodTestStatus::NotTested | MethodTestStatus::Filtered => {
359                    // Skip not tested and filtered methods in summary
360                }
361            }
362        }
363
364        let table = builder.build().with(Style::markdown()).to_string();
365        println!("\n{table}");
366
367        // Print overall summary
368        let total_methods = self.method_reports.len();
369        let tested_methods = self
370            .method_reports
371            .values()
372            .filter(|r| matches!(r.status, MethodTestStatus::Tested { .. }))
373            .count();
374        let failed_methods = self
375            .method_reports
376            .values()
377            .filter(|r| {
378                matches!(
379                    r.status,
380                    MethodTestStatus::Tested { failure_count, .. } if failure_count > 0
381                )
382            })
383            .count();
384
385        println!("\nšŸ“Š Test Summary:");
386        println!("  Total methods: {total_methods}");
387        println!("  Tested methods: {tested_methods}");
388        println!("  Failed methods: {failed_methods}");
389        println!("  Duration: {}s", self.start_time.elapsed().as_secs());
390    }
391
392    /// Finalize and save the report in the provided directory
393    pub fn finalize_and_save(mut self, report_dir: &Path) -> anyhow::Result<()> {
394        // Calculate performance metrics for each method
395        for (method_name, timings) in self.method_timings {
396            if let Some(report) = self.method_reports.get_mut(&method_name) {
397                report.performance = PerformanceMetrics::from_durations(&timings);
398            }
399        }
400
401        let mut methods: Vec<MethodReport> = self.method_reports.into_values().collect();
402        methods.sort_by(|a, b| a.name.cmp(&b.name));
403
404        let report = ApiTestReport {
405            execution_datetime_utc: Utc::now(),
406            total_duration_secs: self.start_time.elapsed(),
407            methods,
408        };
409
410        if !report_dir.is_dir() {
411            std::fs::create_dir_all(report_dir)?;
412        }
413
414        let file_name = match self.report_mode {
415            ReportMode::Full => "full_report.json",
416            ReportMode::FailureOnly => "failure_report.json",
417            ReportMode::Summary => "summary_report.json",
418        };
419
420        std::fs::write(
421            report_dir.join(file_name),
422            serde_json::to_string_pretty(&report)?,
423        )?;
424        Ok(())
425    }
426}
427
428/// Generate a diff between forest and lotus responses
429pub fn generate_diff(forest_json: &serde_json::Value, lotus_json: &serde_json::Value) -> String {
430    let forest_pretty = serde_json::to_string_pretty(forest_json).unwrap_or_default();
431    let lotus_pretty = serde_json::to_string_pretty(lotus_json).unwrap_or_default();
432    let diff = TextDiff::from_lines(&forest_pretty, &lotus_pretty);
433
434    let mut diff_text = String::new();
435    for change in diff.iter_all_changes() {
436        let sign = match change.tag() {
437            ChangeTag::Delete => "-",
438            ChangeTag::Insert => "+",
439            ChangeTag::Equal => " ",
440        };
441        diff_text.push_str(&format!("{sign}{change}"));
442    }
443    diff_text
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use std::time::Duration;
450
451    #[test]
452    fn test_performance_metrics_calculation() {
453        let durations = vec![
454            Duration::from_millis(100),
455            Duration::from_millis(200),
456            Duration::from_millis(300),
457            Duration::from_millis(400),
458            Duration::from_millis(500),
459        ];
460        let metrics = PerformanceMetrics::from_durations(&durations).unwrap();
461
462        assert_eq!(metrics.test_count, 5);
463        assert_eq!(metrics.total_duration_ms.as_millis(), 1500);
464        assert_eq!(metrics.average_duration_ms.as_millis(), 300);
465        assert_eq!(metrics.min_duration_ms.as_millis(), 100);
466        assert_eq!(metrics.max_duration_ms.as_millis(), 500);
467    }
468
469    #[test]
470    fn test_performance_metrics_empty() {
471        let durations: Vec<Duration> = vec![];
472        let metrics = PerformanceMetrics::from_durations(&durations);
473        assert!(metrics.is_none());
474    }
475
476    #[test]
477    fn test_api_versions_render() {
478        assert_eq!(BTreeSet::<ApiPaths>::new().iter().join(", "), "");
479        assert_eq!(BTreeSet::from([ApiPaths::V1]).iter().join(", "), "V1");
480        // Ordering is deterministic regardless of insertion order.
481        assert_eq!(
482            BTreeSet::from([ApiPaths::V2, ApiPaths::V0, ApiPaths::V1])
483                .iter()
484                .join(", "),
485            "V0, V1, V2"
486        );
487    }
488
489    #[test]
490    fn test_performance_metrics_single_value() {
491        let durations = vec![Duration::from_millis(150)];
492        let metrics = PerformanceMetrics::from_durations(&durations).unwrap();
493
494        assert_eq!(metrics.test_count, 1);
495        assert_eq!(metrics.total_duration_ms.as_millis(), 150);
496        assert_eq!(metrics.average_duration_ms.as_millis(), 150);
497        assert_eq!(metrics.min_duration_ms.as_millis(), 150);
498        assert_eq!(metrics.max_duration_ms.as_millis(), 150);
499    }
500}