use std::{process::Output, time::Duration};
use ratatui::{
crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{Block, Cell, Clear, Paragraph, Row, Table, TableState, Wrap},
Frame,
};
use ratatui_textarea::{CursorMove, TextArea};
use serde::{Deserialize, Serialize};
use strum::AsRefStr;
use throbber_widgets_tui::{Throbber, ThrobberState};
use tokio::{process::Command as AsyncCommand, select, sync::oneshot, task, time::sleep};
use crate::{errors::Error, LogScreen, PostEventAction};
pub struct TestsScreen {
test_output: TestOutput,
table_state: TableState,
show_skipped: bool,
show_passed: bool,
show_failed: bool,
packages_path: String,
additional_args: Vec<String>,
area: Option<Rect>,
stdout_read_channel: Option<oneshot::Receiver<tokio::io::Result<Output>>>,
progress_state: ThrobberState,
mode: Option<Mode>,
search_text: TextArea<'static>,
search_result: Vec<usize>,
}
impl Default for TestsScreen {
fn default() -> Self {
Self::new()
}
}
impl TestsScreen {
pub fn new() -> Self {
let mut text_area = TextArea::default();
text_area.set_cursor_line_style(Style::default());
Self {
test_output: TestOutput::default(),
table_state: TableState::new().with_selected(Some(0)),
show_skipped: true,
show_passed: true,
show_failed: true,
packages_path: String::from("./..."),
additional_args: Vec::new(),
area: None,
stdout_read_channel: None,
progress_state: ThrobberState::default(),
mode: None,
search_text: text_area,
search_result: Vec::new(),
}
}
pub fn start_loading_packages(&mut self) -> Result<(), Error> {
let (tx, rx) = oneshot::channel();
task::spawn({
let packages_path = self.packages_path.clone();
let additional_args = self.additional_args.clone();
async {
tx.send(
AsyncCommand::new("go")
.arg("test")
.arg("-count=1")
.arg("-json")
.arg(packages_path)
.args(additional_args)
.output()
.await,
)
}
});
self.test_output.clear();
self.stdout_read_channel = Some(rx);
Ok(())
}
pub fn with_show_skipped(mut self, show_skipped: bool) -> Self {
self.show_skipped = show_skipped;
self
}
pub fn with_show_failed(mut self, show_failed: bool) -> Self {
self.show_failed = show_failed;
self
}
pub fn with_show_passed(mut self, show_passed: bool) -> Self {
self.show_passed = show_passed;
self
}
pub fn with_additional_args(mut self, additional_args: Vec<String>) -> Self {
self.additional_args = additional_args;
self
}
pub fn with_packages_path(mut self, packages_path: String) -> Self {
self.packages_path = packages_path;
self
}
pub async fn update(&mut self) -> Result<bool, Error> {
if let Some(channel) = self.stdout_read_channel.as_mut() {
select! {
Ok(Ok(output)) = channel => {
self.test_output = parse_logs(String::from_utf8(output.stdout)?)?;
self.stdout_read_channel = None;
}
_ = sleep(Duration::from_millis(100)) => {
self.progress_state.calc_next();
},
}
return Ok(true);
}
Ok(false)
}
pub fn handle_event(&mut self, event: Event) -> Result<Option<PostEventAction>, Error> {
match self.mode {
Some(Mode::SearchInput) => self.handle_search_input_mode_event(&event),
None => self.handle_no_mode_event(event),
}
}
fn handle_no_mode_event(&mut self, event: Event) -> Result<Option<PostEventAction>, Error> {
match event {
Event::Key(_) if self.test_output.build_output.failed => {
self.test_output.build_output.clear();
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Esc | KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
}) => Ok(Some(PostEventAction::PopScreen)),
Event::Key(KeyEvent {
code: KeyCode::Down | KeyCode::Char('j'),
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.table_state.select_next();
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Up | KeyCode::Char('k'),
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.table_state.select_previous();
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Left | KeyCode::Char('h'),
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
if let Some(selected_idx) = self.table_state.selected() {
let visible_list_items = self.visible_list_items();
match visible_list_items.get(selected_idx) {
Some(ListItem::Package(selected_package)) => {
let package_index = self
.test_output
.packages
.iter()
.position(|package| package.name == selected_package.name)
.unwrap();
self.test_output.packages[package_index].expanded = false;
self.table_state.select(Some(selected_idx));
}
Some(ListItem::TestCase(selected_test_case)) => {
let package_index = self
.test_output
.packages
.iter()
.position(|package| package.name == selected_test_case.package_name)
.unwrap();
let new_selected_index = visible_list_items
.iter()
.position(|item| {
if let ListItem::Package(package) = item {
package.name == selected_test_case.package_name
} else {
false
}
})
.unwrap();
self.test_output.packages[package_index].expanded = false;
self.table_state.select(Some(new_selected_index));
}
None => (),
};
self.update_search_result();
}
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Right | KeyCode::Char('l'),
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
if let Some(selected_idx) = self.table_state.selected() {
let visible_list_items = self.visible_list_items();
if let Some(ListItem::Package(selected_package)) =
visible_list_items.get(selected_idx)
{
let package_index = self
.test_output
.packages
.iter()
.position(|package| package.name == selected_package.name)
.unwrap();
self.test_output.packages[package_index].expanded = true;
}
self.update_search_result();
}
Ok(None)
}
Event::Key(
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}
| KeyEvent {
code: KeyCode::PageDown,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
},
) => {
if let Some(area) = self.area
&& let Some(selected) = self.table_state.selected_mut() {
*selected = selected.saturating_add(area.height as usize);
}
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
if let Some(area) = self.area
&& let Some(selected) = self.table_state.selected_mut() {
*selected = selected.saturating_add(area.height as usize / 2);
}
Ok(None)
}
Event::Key(
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}
| KeyEvent {
code: KeyCode::PageUp,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
},
) => {
if let Some(area) = self.area
&& let Some(selected) = self.table_state.selected_mut() {
*selected = selected.saturating_sub(area.height as usize);
}
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Char('u'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
if let Some(area) = self.area
&& let Some(selected) = self.table_state.selected_mut() {
*selected = selected.saturating_sub(area.height as usize / 2);
}
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Home | KeyCode::Char('g'),
kind: KeyEventKind::Press,
..
}) => {
self.table_state.select(Some(0));
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::End | KeyCode::Char('G'),
kind: KeyEventKind::Press,
..
}) => {
self.table_state
.select(self.visible_list_items().len().checked_sub(1));
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Char('F'),
kind: KeyEventKind::Press,
..
}) => {
self.show_failed = !self.show_failed;
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Char('P'),
kind: KeyEventKind::Press,
..
}) => {
self.show_passed = !self.show_passed;
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Char('S'),
kind: KeyEventKind::Press,
..
}) => {
self.show_skipped = !self.show_skipped;
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
}) => Ok(self.table_state.selected().map(|selected_idx| {
let log = match self.visible_list_items()[selected_idx] {
ListItem::Package(package) => package.log.clone(),
ListItem::TestCase(test_case) => test_case.log.clone(),
};
PostEventAction::PushScreen(
LogScreen::new(log.lines().map(String::from).collect::<Vec<_>>()).into(),
)
})),
Event::Key(KeyEvent {
code: KeyCode::Char('r'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
..
}) => {
self.start_loading_packages()?;
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Char('/'),
kind: KeyEventKind::Press,
..
}) => {
self.mode = Some(Mode::SearchInput);
self.search_text.move_cursor(CursorMove::End);
self.search_text.delete_line_by_head();
self.search_result.clear();
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Char('p'),
..
}) => {
if !self.search_result.is_empty() {
self.table_state
.select(self.table_state.selected().and_then(|selected| {
self.search_result
.iter()
.rev()
.find(|i| **i < selected)
.or_else(|| self.search_result.last())
.copied()
}));
}
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Char('n'),
..
}) => {
if !self.search_result.is_empty() {
self.table_state
.select(self.table_state.selected().and_then(|selected| {
self.search_result
.iter()
.find(|i| **i > selected)
.or_else(|| self.search_result.first())
.copied()
}));
}
Ok(None)
}
_ => Ok(None),
}
}
fn handle_search_input_mode_event(
&mut self,
event: &Event,
) -> Result<Option<PostEventAction>, Error> {
match event {
Event::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press,
..
}) => {
self.mode = None;
self.search_result.clear();
Ok(None)
}
Event::Key(KeyEvent {
code: KeyCode::Enter,
..
}) => {
self.mode = None;
Ok(None)
}
Event::Key(event) => {
if self.search_text.input(*event) {
self.update_search_result();
self.table_state.select(self.search_result.first().copied());
}
Ok(None)
}
_ => Ok(None),
}
}
pub fn render(&mut self, frame: &mut Frame) {
self.area = Some(frame.area());
frame.render_widget(Clear, frame.area());
if self.stdout_read_channel.is_some() {
let [text_area, spinner_area] = Layout::horizontal([
Constraint::Length("Tests running ".len() as u16),
Constraint::Length(1),
])
.areas(frame.area());
frame.render_widget(Span::raw("Tests running"), text_area);
frame.render_stateful_widget(
Throbber::default(),
spinner_area,
&mut self.progress_state,
);
} else {
self.render_main_frame(frame);
if self.test_output.build_output.failed {
self.render_error_popup(frame);
}
}
}
fn render_main_frame(&mut self, frame: &mut Frame) {
let visible_items = self.visible_list_items();
let visible_item_len = visible_items.len();
let rows: Vec<Row> = visible_items
.into_iter()
.enumerate()
.map(|(y, item)| item.render(y == visible_item_len - 1))
.collect();
let [table_rect, footer_rect, command_rect] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(frame.area());
frame.render_stateful_widget(
Table::new(rows, [Constraint::Fill(1), Constraint::Length(8)])
.header(Row::new(vec!["Test case", "Elapsed"]))
.row_highlight_style(Style::default().reversed()),
table_rect,
&mut self.table_state,
);
let (passed, failed, skipped) =
self.test_output
.packages
.iter()
.fold((0, 0, 0), |mut acc, package| {
for test_case in &package.tests {
match test_case.result {
Some(TestResult::Pass) => acc.0 += 1,
Some(TestResult::Fail) => acc.1 += 1,
Some(TestResult::Skip) => acc.2 += 1,
None => (),
}
}
acc
});
let passed_color = if self.show_passed {
TestResult::Pass.marker_color()
} else {
Color::DarkGray
};
let failed_color = if self.show_failed {
TestResult::Fail.marker_color()
} else {
Color::DarkGray
};
let skipped_color = if self.show_skipped {
TestResult::Skip.marker_color()
} else {
Color::DarkGray
};
frame.render_widget(
Line::from_iter([
format!("{} {} ", TestResult::Pass.marker_char(), passed).fg(passed_color),
format!("{} {} ", TestResult::Fail.marker_char(), failed).fg(failed_color),
format!("{} {} ", TestResult::Skip.marker_char(), skipped).fg(skipped_color),
]),
footer_rect,
);
if let Some(Mode::SearchInput) = self.mode {
let [prefix_area, search_text_area] = Layout::horizontal([
Constraint::Length("search:".len() as u16),
Constraint::Fill(1),
])
.areas(command_rect);
frame.render_widget(Span::from("search:"), prefix_area);
frame.render_widget(&self.search_text, search_text_area);
}
}
fn render_error_popup(&self, frame: &mut Frame) {
let popup_area = frame
.area()
.centered(Constraint::Percentage(75), Constraint::Percentage(75));
frame.render_widget(Clear, popup_area);
frame.render_widget(
Paragraph::new(
self.test_output
.build_output
.lines
.iter()
.map(Line::raw)
.collect::<Vec<_>>(),
)
.block(
Block::bordered()
.border_style(Color::Red)
.title("Build failed"),
)
.wrap(Wrap::default()),
popup_area,
);
}
fn update_search_result(&mut self) {
self.search_result = self
.visible_list_items()
.iter()
.enumerate()
.filter(|(_, item)| {
item.name().contains(
self.search_text
.lines()
.first()
.expect("Expected at least 1 line in search text area"),
)
})
.map(|(idx, _)| idx)
.collect();
}
fn visible_list_items(&self) -> Vec<ListItem<'_>> {
self.test_output
.packages
.iter()
.flat_map(|package| {
if !self.is_item_visible_with_result(&package.result) {
return Vec::new();
}
let mut list_items = vec![ListItem::Package(package)];
if !package.expanded {
return list_items;
}
for test in package.tests.iter() {
if self.is_item_visible_with_result(&test.result) {
list_items.push(ListItem::TestCase(test));
}
}
list_items
})
.collect()
}
fn is_item_visible_with_result(&self, result: &Option<TestResult>) -> bool {
match result {
Some(TestResult::Skip) | None => self.show_skipped,
Some(TestResult::Pass) => self.show_passed,
Some(TestResult::Fail) => self.show_failed,
}
}
}
#[derive(Debug)]
enum ListItem<'a> {
Package(&'a Package),
TestCase(&'a TestCase),
}
impl<'a> ListItem<'a> {
fn name(&self) -> &str {
match self {
ListItem::Package(package) => package.name.as_str(),
ListItem::TestCase(test_case) => test_case.name.as_str(),
}
}
fn render<'b>(&self, is_last_element: bool) -> Row<'b> {
match self {
ListItem::Package(package) => {
let test_result = package.result.unwrap_or(TestResult::Skip);
let marker_color = test_result.marker_color();
let marker_char = test_result.marker_char();
Row::new(vec![
Cell::from(Line::from(vec![
Span::from(marker_char.to_string()).fg(marker_color),
Span::from(" "),
Span::from(package.name.clone()),
])),
Cell::from(
package
.elapsed
.map(|elapsed| format!("{}s", elapsed))
.unwrap_or_default(),
),
])
}
ListItem::TestCase(test_case) => {
let border = if is_last_element { "└" } else { "├" };
let test_result = test_case.result.unwrap_or(TestResult::Skip);
let marker_color = test_result.marker_color();
let marker_char = test_result.marker_char();
Row::new(vec![
Cell::from(Line::from(vec![
Span::from(border),
Span::from(" "),
Span::from(marker_char.to_string()).fg(marker_color),
Span::from(" "),
Span::from(test_case.name.clone()),
])),
Cell::from(
test_case
.elapsed
.map(|elapsed| format!("{}s", elapsed))
.unwrap_or_default(),
),
])
}
}
}
}
fn parse_logs(input: impl AsRef<str>) -> Result<TestOutput, Error> {
input
.as_ref()
.lines()
.try_fold(TestOutput::default(), |mut acc, line| {
let line: LogLine = serde_json::from_str(line)?;
match line.action {
Some(Action::Start) => acc.packages.push(Package::new(
line.package
.expect("Expected name for package in `Start` action"),
)),
Some(action @ (Action::Skip | Action::Pass | Action::Fail)) => {
if let Some(package) = acc.packages.iter_mut().find(|package| {
package.name
== line.package.as_deref().unwrap_or_else(|| panic!("Expected name for package in `{}` action",
action.as_ref()))
}) {
if let Some(test) = package
.tests
.iter_mut()
.find(|test| line.test.as_deref() == Some(&test.name))
{
test.result = Some(action.try_into().unwrap());
test.elapsed = line.elapsed;
} else {
package.result = Some(action.try_into().unwrap());
package.elapsed = line.elapsed;
}
}
}
Some(Action::Run) => {
if let Some(package) = acc.packages.iter_mut().find(|package| {
package.name
== line
.package
.as_deref()
.expect("Expected name for package in `Run` action")
}) {
package.tests.push(TestCase {
package_name: package.name.clone(),
name: line.test.expect("Expected test name"),
result: None,
log: String::new(),
elapsed: None,
});
}
}
Some(Action::Output) => {
if let Some(test_case) = &line.test {
if let Some(test) = acc
.packages
.iter_mut()
.find(|package| {
package.name
== line
.package
.as_deref()
.expect("Expected name for package in `Output` action")
})
.and_then(|package| {
package
.tests
.iter_mut()
.find(|test| test.name == *test_case)
})
{
test.log.push_str(
&line
.output
.expect("Expected output in `Output` action")
.replace('\t', " "),
);
}
} else if let Some(package) = acc.packages.iter_mut().find(|package| {
package.name
== line
.package
.as_deref()
.expect("Expected name for package in `Output` action")
}) {
package.log.push_str(
&line
.output
.expect("Expected output in `Output` action")
.replace('\t', " "),
);
}
}
Some(Action::BuildOutput) => {
acc.build_output.lines.push(line.output.unwrap_or_default());
}
Some(Action::BuildFail) => {
acc.build_output.failed = true;
}
None => (),
}
Ok(acc)
})
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, AsRefStr)]
#[serde(rename_all = "kebab-case")]
enum Action {
BuildOutput,
BuildFail,
Start,
Run,
Output,
Pass,
Fail,
Skip,
}
#[derive(Debug, Clone, Copy, AsRefStr, Default, PartialEq, Eq, PartialOrd, Ord)]
#[strum(serialize_all = "lowercase")]
enum TestResult {
#[default]
Skip,
Fail,
Pass,
}
impl TestResult {
fn marker_color(&self) -> Color {
match self {
TestResult::Pass => Color::Green,
TestResult::Fail => Color::Red,
TestResult::Skip => Color::Gray,
}
}
fn marker_char(&self) -> char {
match self {
TestResult::Pass => '',
TestResult::Fail => '',
TestResult::Skip => '',
}
}
}
impl TryFrom<Action> for TestResult {
type Error = String;
fn try_from(value: Action) -> Result<Self, Self::Error> {
match value {
Action::Pass => Ok(TestResult::Pass),
Action::Fail => Ok(TestResult::Fail),
Action::Skip => Ok(TestResult::Skip),
action => Err(format!(
"Action `{}` is not a valid TestResult",
action.as_ref()
)),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all(deserialize = "PascalCase"))]
struct LogLine {
action: Option<Action>,
package: Option<String>,
test: Option<String>,
output: Option<String>,
elapsed: Option<f64>,
}
#[derive(Debug, Clone, Default)]
pub struct BuildOutput {
lines: Vec<String>,
failed: bool,
}
impl BuildOutput {
fn clear(&mut self) {
*self = BuildOutput::default();
}
}
#[derive(Debug, Clone, Default)]
pub struct TestOutput {
packages: Vec<Package>,
build_output: BuildOutput,
}
impl TestOutput {
fn clear(&mut self) {
self.packages.clear();
self.build_output = BuildOutput::default();
}
}
#[derive(Debug, Clone, Default)]
pub struct Package {
name: String,
result: Option<TestResult>,
elapsed: Option<f64>,
tests: Vec<TestCase>,
log: String,
expanded: bool,
}
impl Package {
fn new<T: Into<String>>(name: T) -> Self {
Self {
name: name.into(),
..Self::default()
}
}
}
#[derive(Debug, Clone)]
struct TestCase {
package_name: String,
name: String,
result: Option<TestResult>,
elapsed: Option<f64>,
log: String,
}
#[derive(Debug, Clone, Copy)]
enum Mode {
SearchInput,
}