1#![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#[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#[serde_as]
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63struct SuccessfulTest {
64 request_params: serde_json::Value,
66
67 forest_status: TestSummary,
69
70 lotus_status: TestSummary,
72
73 #[serde_as(as = "DurationMilliSeconds<u64>")]
75 execution_duration_ms: Duration,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case", tag = "type")]
81enum MethodTestStatus {
82 Tested {
84 total_count: usize,
85 success_count: usize,
86 failure_count: usize,
87 },
88 Filtered,
90 NotTested,
92}
93
94#[serde_as]
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98struct FailedTest {
99 pub request_params: serde_json::Value,
101
102 pub forest_status: TestSummary,
104
105 pub lotus_status: TestSummary,
107
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub response_diff: Option<String>,
111
112 #[serde_as(as = "DurationMilliSeconds<u64>")]
114 pub execution_duration_ms: Duration,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120struct MethodReport {
121 name: String,
123
124 permission: Permission,
126
127 status: MethodTestStatus,
129
130 #[serde(skip_serializing_if = "BTreeSet::is_empty")]
132 api_versions: BTreeSet<ApiPaths>,
133
134 #[serde(skip_serializing_if = "Option::is_none")]
136 performance: Option<PerformanceMetrics>,
137
138 #[serde(skip_serializing_if = "Vec::is_empty")]
140 success_test_params: Vec<SuccessfulTest>,
141
142 #[serde(skip_serializing_if = "Vec::is_empty")]
144 failed_test_params: Vec<FailedTest>,
145}
146
147#[serde_as]
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub struct ApiTestReport {
152 #[serde_as(as = "DisplayFromStr")]
154 execution_datetime_utc: DateTime<Utc>,
155
156 #[serde_as(as = "DurationSeconds<u64>")]
158 total_duration_secs: Duration,
159
160 methods: Vec<MethodReport>,
162}
163
164pub 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 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 self.method_timings
243 .entry(method_name.to_string())
244 .or_default()
245 .push(test_result.duration);
246
247 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 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 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 pub fn print_summary(&mut self) {
301 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 }
361 }
362 }
363
364 let table = builder.build().with(Style::markdown()).to_string();
365 println!("\n{table}");
366
367 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 pub fn finalize_and_save(mut self, report_dir: &Path) -> anyhow::Result<()> {
394 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
428pub 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 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}