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