1use console::{style, StyledObject, Term};
39use eyre::WrapErr;
40use indexmap::IndexMap;
41use itertools::Itertools;
42use std::{
43 collections::HashMap,
44 sync::{LazyLock, Mutex},
45};
46use tokio::sync::broadcast;
47use tracing::*;
48
49use crate::{
50 get_tanu_config, http,
51 runner::{self, Event, EventBody, Test},
52 ModuleName, ProjectName, TestName,
53};
54
55#[derive(Debug, Clone, Default, strum::EnumString, strum::Display)]
66#[strum(serialize_all = "snake_case")]
67pub enum ReporterType {
68 Null,
69 #[default]
70 List,
71 Table,
72}
73
74async fn run<R: Reporter + Send + ?Sized>(reporter: &mut R) -> eyre::Result<()> {
75 let rx_result = runner::subscribe();
77
78 runner::wait_reporter_barrier().await;
81
82 let mut rx = rx_result?;
84
85 loop {
86 let res = match rx.recv().await {
87 Ok(Event {
88 project,
89 module,
90 test,
91 body: EventBody::Start,
92 }) => reporter.on_start(project, module, test).await,
93 Ok(Event {
94 project,
95 module,
96 test,
97 body: EventBody::Check(check),
98 }) => reporter.on_check(project, module, test, check).await,
99 Ok(Event {
100 project,
101 module,
102 test,
103 body: EventBody::Call(log),
104 }) => reporter.on_call(project, module, test, log).await,
105 Ok(Event {
106 project,
107 module,
108 test: test_name,
109 body: EventBody::Retry(test),
110 }) => reporter.on_retry(project, module, test_name, test).await,
111 Ok(Event {
112 project,
113 module,
114 test: test_name,
115 body: EventBody::End(test),
116 }) => reporter.on_end(project, module, test_name, test).await,
117 Ok(Event {
118 project: _,
119 module: _,
120 test: _,
121 body: EventBody::Summary(summary),
122 }) => reporter.on_summary(summary).await,
123 Err(broadcast::error::RecvError::Closed) => {
124 debug!("runner channel has been closed");
125 break;
126 }
127 Err(broadcast::error::RecvError::Lagged(_)) => {
128 debug!("runner channel recv error");
129 continue;
130 }
131 };
132
133 if let Err(e) = res {
134 warn!("reporter error: {e:#}");
135 }
136 }
137
138 Ok(())
139}
140
141#[async_trait::async_trait]
191pub trait Reporter {
192 async fn run(&mut self) -> eyre::Result<()> {
193 run(self).await
194 }
195
196 async fn on_start(
198 &mut self,
199 _project: String,
200 _module: String,
201 _test_name: String,
202 ) -> eyre::Result<()> {
203 Ok(())
204 }
205
206 async fn on_check(
208 &mut self,
209 _project: String,
210 _module: String,
211 _test_name: String,
212 _check: Box<runner::Check>,
213 ) -> eyre::Result<()> {
214 Ok(())
215 }
216
217 async fn on_call(
219 &mut self,
220 _project: String,
221 _module: String,
222 _test_name: String,
223 _log: runner::CallLog,
224 ) -> eyre::Result<()> {
225 Ok(())
226 }
227
228 async fn on_retry(
230 &mut self,
231 _project: String,
232 _module: String,
233 _test_name: String,
234 _test: Test,
235 ) -> eyre::Result<()> {
236 Ok(())
237 }
238
239 async fn on_end(
241 &mut self,
242 _project: String,
243 _module: String,
244 _test_name: String,
245 _test: Test,
246 ) -> eyre::Result<()> {
247 Ok(())
248 }
249
250 async fn on_summary(&mut self, _summary: runner::TestSummary) -> eyre::Result<()> {
252 Ok(())
253 }
254}
255
256pub struct NullReporter;
271
272#[async_trait::async_trait]
273impl Reporter for NullReporter {}
274
275#[allow(clippy::vec_box)]
277#[derive(Default, Debug)]
278struct Buffer {
279 test_number: Option<usize>,
280 http_logs: Vec<Box<http::Log>>,
281 #[cfg(feature = "grpc")]
282 grpc_logs: Vec<Box<crate::grpc::Log>>,
283}
284
285fn generate_test_number() -> usize {
286 static TEST_NUMBER: LazyLock<Mutex<usize>> = LazyLock::new(|| Mutex::new(0));
287 let mut test_number = TEST_NUMBER.lock().unwrap();
288 *test_number += 1;
289 *test_number
290}
291pub struct ListReporter {
330 terminal: Term,
331 buffer: IndexMap<(ProjectName, ModuleName, TestName), Buffer>,
332 capture_http: bool,
333}
334
335impl ListReporter {
336 pub fn new(capture_http: bool) -> ListReporter {
354 ListReporter {
355 terminal: Term::stdout(),
356 buffer: IndexMap::new(),
357 capture_http,
358 }
359 }
360}
361
362#[async_trait::async_trait]
363impl Reporter for ListReporter {
364 async fn on_start(
365 &mut self,
366 project_name: String,
367 module_name: String,
368 test_name: String,
369 ) -> eyre::Result<()> {
370 self.buffer
371 .insert((project_name, module_name, test_name), Buffer::default());
372 Ok(())
373 }
374
375 async fn on_call(
376 &mut self,
377 project_name: String,
378 module_name: String,
379 test_name: String,
380 log: runner::CallLog,
381 ) -> eyre::Result<()> {
382 if self.capture_http {
383 let buffer = self
384 .buffer
385 .get_mut(&(project_name, module_name, test_name.clone()))
386 .ok_or_else(|| eyre::eyre!("test case \"{test_name}\" not found in the buffer"))?;
387 match log {
388 runner::CallLog::Http(http_log) => buffer.http_logs.push(http_log),
389 #[cfg(feature = "grpc")]
390 runner::CallLog::Grpc(grpc_log) => buffer.grpc_logs.push(grpc_log),
391 }
392 }
393 Ok(())
394 }
395
396 async fn on_retry(
397 &mut self,
398 project_name: String,
399 module_name: String,
400 test_name: String,
401 test: Test,
402 ) -> eyre::Result<()> {
403 let buffer = self
404 .buffer
405 .get_mut(&(project_name.clone(), module_name.clone(), test_name.clone()))
406 .ok_or_else(|| eyre::eyre!("test case \"{test_name}\" not found in the buffer",))?;
407
408 let test_number = style(buffer.test_number.get_or_insert_with(generate_test_number)).dim();
409
410 if let Err(e) = test.result {
411 self.terminal.write_line(&format!(
412 "{status} {test_number} {project} {path}: {retry_message}\n{error}",
413 status = symbol_error(),
414 project = style_project(&project_name),
415 path = style_module_path(&module_name, &test_name),
416 retry_message = style("retrying...").blue(),
417 error = style(format!("{e:#}")).dim(),
418 ))?;
419 }
420 Ok(())
421 }
422
423 async fn on_end(
424 &mut self,
425 project_name: String,
426 module_name: String,
427 test_name: String,
428 test: Test,
429 ) -> eyre::Result<()> {
430 let mut buffer = self
431 .buffer
432 .swap_remove(&(project_name.clone(), module_name, test_name.clone()))
433 .ok_or_else(|| eyre::eyre!("test case \"{test_name}\" not found in the buffer"))?;
434
435 for log in buffer.http_logs {
436 self.terminal.write_line(&format!(
438 " {} {} {}",
439 style("=>").cyan(),
440 style_http_method(log.request.method.as_ref()),
441 style(&log.request.url.to_string()).underlined()
442 ))?;
443 self.terminal.write_line(&format!(
445 " {} {}",
446 style(">").cyan(),
447 style("request:").cyan()
448 ))?;
449 self.terminal.write_line(&format!(
450 " {} {}",
451 style(">").cyan(),
452 style("headers:").dim()
453 ))?;
454 for key in log.request.headers.keys() {
455 self.terminal.write_line(&format!(
456 " {} {}: {}",
457 style(">").cyan(),
458 style(key.as_str()).bold(),
459 style(log.request.headers.get(key).unwrap().to_str().unwrap()).dim()
460 ))?;
461 }
462 self.terminal.write_line(&format!(
464 " {} {} {}",
465 style("<").yellow(),
466 style("response:").yellow(),
467 style_status_code(log.response.status.as_u16())
468 ))?;
469 self.terminal.write_line(&format!(
470 " {} {}",
471 style("<").yellow(),
472 style("headers:").dim()
473 ))?;
474 for key in log.response.headers.keys() {
475 self.terminal.write_line(&format!(
476 " {} {}: {}",
477 style("<").yellow(),
478 style(key.as_str()).bold(),
479 style(log.response.headers.get(key).unwrap().to_str().unwrap()).dim()
480 ))?;
481 }
482 self.terminal.write_line(&format!(
483 " {} {} {}",
484 style("<").yellow(),
485 style("body:").dim(),
486 style(&log.response.body).dim()
487 ))?;
488 }
489
490 #[cfg(feature = "grpc")]
492 for log in buffer.grpc_logs {
493 self.terminal.write_line(&format!(
495 " {} {} {}",
496 style("=>").magenta(),
497 style("gRPC").magenta().bold(),
498 style(&log.request.method).underlined()
499 ))?;
500 self.terminal.write_line(&format!(
502 " {} {}",
503 style(">").magenta(),
504 style("request:").magenta()
505 ))?;
506 self.terminal.write_line(&format!(
507 " {} {}",
508 style(">").magenta(),
509 style("metadata:").dim()
510 ))?;
511 for key_value in log.request.metadata.iter() {
512 let (key, value) = match key_value {
513 tonic::metadata::KeyAndValueRef::Ascii(k, v) => (
514 k.as_str().to_string(),
515 v.to_str().unwrap_or("<binary>").to_string(),
516 ),
517 tonic::metadata::KeyAndValueRef::Binary(k, v) => (
518 k.as_str().to_string(),
519 format!("<binary: {} bytes>", v.as_encoded_bytes().len()),
520 ),
521 };
522 self.terminal.write_line(&format!(
523 " {} {}: {}",
524 style(">").magenta(),
525 style(&key).bold(),
526 style(&value).dim()
527 ))?;
528 }
529 if !log.request.message.is_empty() {
530 self.terminal.write_line(&format!(
531 " {} {} {}",
532 style(">").magenta(),
533 style("message:").dim(),
534 style(format!("{} bytes", log.request.message.len())).dim()
535 ))?;
536 }
537 self.terminal.write_line(&format!(
539 " {} {} {}",
540 style("<").yellow(),
541 style("response:").yellow(),
542 style_grpc_status(log.response.status_code)
543 ))?;
544 self.terminal.write_line(&format!(
545 " {} {}",
546 style("<").yellow(),
547 style("metadata:").dim()
548 ))?;
549 for key_value in log.response.metadata.iter() {
550 let (key, value) = match key_value {
551 tonic::metadata::KeyAndValueRef::Ascii(k, v) => (
552 k.as_str().to_string(),
553 v.to_str().unwrap_or("<binary>").to_string(),
554 ),
555 tonic::metadata::KeyAndValueRef::Binary(k, v) => (
556 k.as_str().to_string(),
557 format!("<binary: {} bytes>", v.as_encoded_bytes().len()),
558 ),
559 };
560 self.terminal.write_line(&format!(
561 " {} {}: {}",
562 style("<").yellow(),
563 style(&key).bold(),
564 style(&value).dim()
565 ))?;
566 }
567 if !log.response.message.is_empty() {
568 self.terminal.write_line(&format!(
569 " {} {} {}",
570 style("<").yellow(),
571 style("message:").dim(),
572 style(format!("{} bytes", log.response.message.len())).dim()
573 ))?;
574 }
575 if !log.response.status_message.is_empty() {
576 self.terminal.write_line(&format!(
577 " {} {} {}",
578 style("<").yellow(),
579 style("status_message:").dim(),
580 style(&log.response.status_message).dim()
581 ))?;
582 }
583 }
584
585 let status = symbol_test_result(&test);
586 let Test {
587 result,
588 info,
589 request_time,
590 started_at: _,
591 ended_at: _,
592 worker_id: _,
593 } = test;
594 let test_number = style(buffer.test_number.get_or_insert_with(generate_test_number)).dim();
595 let request_time = style(format!("({request_time:.2?})")).dim();
596 let project = style_project(&project_name);
597 let path = style_module_path(&info.module, &info.name);
598 match result {
599 Ok(_res) => {
600 self.terminal.write_line(&format!(
601 "{status} {test_number} {project} {path} {request_time}"
602 ))?;
603 }
604 Err(e) => {
605 self.terminal.write_line(&format!(
606 "{status} {test_number} {project} {path} {request_time}:\n{error}",
607 error = style(format!("{e:#}")).red()
608 ))?;
609 }
610 }
611
612 Ok(())
613 }
614
615 async fn on_summary(&mut self, summary: runner::TestSummary) -> eyre::Result<()> {
616 let runner::TestSummary {
617 total_tests,
618 passed_tests,
619 failed_tests,
620 total_time,
621 test_prep_time,
622 } = summary;
623
624 self.terminal.write_line("")?;
625 self.terminal.write_line(&format!(
626 "{}: {} {}, {} {}, {} {}",
627 style("Tests").bold(),
628 style(passed_tests).green().bold(),
629 style("passed").green(),
630 if failed_tests > 0 {
631 style(failed_tests).red().bold()
632 } else {
633 style(failed_tests).bold()
634 },
635 if failed_tests > 0 {
636 style("failed").red()
637 } else {
638 style("failed")
639 },
640 style(total_tests).bold(),
641 style("total").dim()
642 ))?;
643 self.terminal.write_line(&format!(
644 "{}: {} ({}: {})",
645 style("Time").bold(),
646 style(format!("{total_time:.2?}")).cyan(),
647 style("prep").dim(),
648 style(format!("{test_prep_time:.2?}")).dim()
649 ))?;
650
651 Ok(())
652 }
653}
654
655fn write(term: &Term, s: impl AsRef<str>) -> eyre::Result<()> {
656 let colored = style(s.as_ref()).dim();
657 term.write_line(&format!("{colored}"))
658 .wrap_err("failed to write character on terminal")
659}
660
661fn symbol_test_result(test: &Test) -> StyledObject<&'static str> {
662 match test.result {
663 Ok(_) => symbol_success(),
664 Err(_) => symbol_error(),
665 }
666}
667
668fn symbol_success() -> StyledObject<&'static str> {
669 style("✓").green()
670}
671
672fn symbol_error() -> StyledObject<&'static str> {
673 style("✘").red()
674}
675
676fn emoji_symbol_test_result(test: &Test) -> char {
677 match test.result {
678 Ok(_) => '🟢',
679 Err(_) => '🔴',
680 }
681}
682
683fn style_http_method(method: &str) -> StyledObject<&str> {
685 match method.to_uppercase().as_str() {
686 "GET" => style(method).green(),
687 "POST" => style(method).yellow(),
688 "PUT" => style(method).blue(),
689 "DELETE" => style(method).red(),
690 "PATCH" => style(method).magenta(),
691 "HEAD" => style(method).cyan(),
692 "OPTIONS" => style(method).white(),
693 _ => style(method),
694 }
695}
696
697fn style_status_code(status: u16) -> StyledObject<String> {
699 let s = status.to_string();
700 match status {
701 100..=199 => style(s).cyan(), 200..=299 => style(s).green(), 300..=399 => style(s).yellow(), 400..=499 => style(s).red(), 500..=599 => style(s).red().bold(), _ => style(s),
707 }
708}
709
710#[cfg(feature = "grpc")]
712fn style_grpc_status(code: tonic::Code) -> StyledObject<String> {
713 let s = format!("{:?}", code);
714 match code {
715 tonic::Code::Ok => style(s).green(),
716 tonic::Code::Cancelled
717 | tonic::Code::Unknown
718 | tonic::Code::DeadlineExceeded
719 | tonic::Code::ResourceExhausted
720 | tonic::Code::Aborted
721 | tonic::Code::Unavailable => style(s).yellow(),
722 tonic::Code::InvalidArgument
723 | tonic::Code::NotFound
724 | tonic::Code::AlreadyExists
725 | tonic::Code::PermissionDenied
726 | tonic::Code::FailedPrecondition
727 | tonic::Code::OutOfRange
728 | tonic::Code::Unauthenticated => style(s).red(),
729 tonic::Code::Unimplemented | tonic::Code::Internal | tonic::Code::DataLoss => {
730 style(s).red().bold()
731 }
732 }
733}
734
735fn style_project(name: &str) -> StyledObject<String> {
737 style(format!("[{name}]")).magenta().bold()
738}
739
740fn style_module_path(module: &str, test: &str) -> String {
742 format!("{}::{}", style(module).cyan(), style(test).blue().bold())
743}
744
745#[allow(clippy::vec_box, dead_code)]
746pub struct TableReporter {
780 terminal: Term,
781 buffer: HashMap<(ProjectName, ModuleName, TestName), Test>,
782 capture_http: bool,
783}
784
785impl TableReporter {
786 pub fn new(capture_http: bool) -> TableReporter {
800 TableReporter {
801 terminal: Term::stdout(),
802 buffer: HashMap::new(),
803 capture_http,
804 }
805 }
806}
807
808#[async_trait::async_trait]
809impl Reporter for TableReporter {
810 async fn run(&mut self) -> eyre::Result<()> {
811 run(self).await?;
812
813 let project_order: Vec<_> = get_tanu_config().projects.iter().map(|p| &p.name).collect();
814
815 let mut builder = tabled::builder::Builder::default();
816 builder.push_record(["Project", "Module", "Test", "Result"]);
817 self.buffer
818 .drain()
819 .sorted_by(|(a, _), (b, _)| {
820 let project_order_a = project_order
821 .iter()
822 .position(|&p| *p == a.0)
823 .unwrap_or(usize::MAX);
824 let project_order_b = project_order
825 .iter()
826 .position(|&p| *p == b.0)
827 .unwrap_or(usize::MAX);
828
829 project_order_a
830 .cmp(&project_order_b)
831 .then(a.1.cmp(&b.1))
832 .then(a.2.cmp(&b.2))
833 })
834 .for_each(|((p, m, t), test)| {
835 builder.push_record([p, m, t, emoji_symbol_test_result(&test).to_string()])
836 });
837
838 let mut table = builder.build();
839 table.with(tabled::settings::Style::modern()).with(
840 tabled::settings::Modify::new(tabled::settings::object::Columns::single(3))
841 .with(tabled::settings::Alignment::center()),
842 );
843
844 write(&self.terminal, format!("{table}")).wrap_err("failed to write table on terminal")?;
845
846 Ok(())
847 }
848
849 async fn on_end(
850 &mut self,
851 project_name: String,
852 module_name: String,
853 test_name: String,
854 test: Test,
855 ) -> eyre::Result<()> {
856 self.buffer
857 .insert((project_name, module_name, test_name), test);
858 Ok(())
859 }
860
861 async fn on_summary(&mut self, summary: runner::TestSummary) -> eyre::Result<()> {
862 let runner::TestSummary {
863 total_tests,
864 passed_tests,
865 failed_tests,
866 total_time,
867 test_prep_time,
868 } = summary;
869
870 self.terminal.write_line("")?;
871 self.terminal.write_line(&format!(
872 "{}: {} {}, {} {}, {} {}",
873 style("Tests").bold(),
874 style(passed_tests).green().bold(),
875 style("passed").green(),
876 if failed_tests > 0 {
877 style(failed_tests).red().bold()
878 } else {
879 style(failed_tests).bold()
880 },
881 if failed_tests > 0 {
882 style("failed").red()
883 } else {
884 style("failed")
885 },
886 style(total_tests).bold(),
887 style("total").dim()
888 ))?;
889 self.terminal.write_line(&format!(
890 "{}: {} ({}: {})",
891 style("Time").bold(),
892 style(format!("{total_time:.2?}")).cyan(),
893 style("prep").dim(),
894 style(format!("{test_prep_time:.2?}")).dim()
895 ))?;
896
897 Ok(())
898 }
899}