use std::io::Cursor;
use clap::Parser;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::TestBackend;
use skim::{
Skim, SkimItemReceiver,
prelude::*,
tui::{Event, Size, Tui, event::Action},
};
pub struct TestHarness {
pub skim: Skim<TestBackend>,
pub runtime: tokio::runtime::Runtime,
pub final_event: Option<Event>,
}
impl TestHarness {
pub fn tick(&mut self) -> Result<()> {
loop {
let mut events = Vec::new();
while let Ok(event) = self.skim.tui_mut().event_rx.try_recv() {
events.push(event);
}
if events.is_empty() {
break;
}
for event in events {
self.process_event(event)?;
}
}
Ok(())
}
fn process_event(&mut self, event: Event) -> Result<()> {
if let Event::Reload(ref new_cmd) = event {
let new_cmd = new_cmd.clone();
self.skim.handle_reload(&new_cmd);
} else {
let _guard = self.runtime.enter();
let (app, tui) = self.skim.app_and_tui();
app.handle_event(tui, &event)?;
}
self.skim.check_reader();
if self.skim.app().should_quit && self.final_event.is_none() {
self.final_event = Some(event);
}
Ok(())
}
pub fn send(&mut self, event: Event) -> Result<()> {
self.skim.tui_mut().event_tx.try_send(event)?;
Ok(())
}
pub fn key(&mut self, key: KeyEvent) -> Result<()> {
self.send(Event::Key(key))?;
self.tick()?;
self.wait_for_completion()?;
Ok(())
}
pub fn char(&mut self, c: char) -> Result<()> {
self.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
}
pub fn type_str(&mut self, s: &str) -> Result<()> {
for c in s.chars() {
self.char(c)?;
}
Ok(())
}
pub fn action(&mut self, action: Action) -> Result<()> {
self.send(Event::Action(action))?;
self.tick()?;
self.wait_for_completion()?;
Ok(())
}
fn wait_for_completion(&mut self) -> Result<()> {
if self.skim.app().pending_matcher_restart {
self.skim.app_mut().restart_matcher(true);
}
if !self.skim.reader_done() {
self.wait_for_reader_and_matcher()?;
} else {
self.wait_for_matcher()?;
}
Ok(())
}
pub fn buffer_view(&self) -> String {
self.skim.tui_ref().backend().to_string()
}
pub fn prepare_snap(&mut self) -> Result<()> {
self.send(Event::Heartbeat)?;
self.tick()?;
self.handle_remaining_events()?;
self.send(Event::Render)?;
self.tick()?;
Ok(())
}
#[doc(hidden)]
pub fn snap(&mut self) -> Result<()> {
self.prepare_snap()?;
let buf = self.buffer_view();
let cursor_pos = format!(
"cursor: {}x{}",
self.skim.app().cursor_pos.0,
self.skim.app().cursor_pos.1
);
insta::assert_snapshot!(buf + &cursor_pos);
Ok(())
}
pub fn wait_for_reader_and_matcher(&mut self) -> Result<()> {
let timeout = std::time::Duration::from_secs(5);
let start = std::time::Instant::now();
let poll_interval = std::time::Duration::from_millis(10);
while !self.skim.reader_done() {
if start.elapsed() > timeout {
return Err(color_eyre::eyre::eyre!("Timeout waiting for reader to finish"));
}
self.skim.check_reader();
std::thread::sleep(poll_interval);
}
self.skim.check_reader();
self.wait_for_matcher()?;
Ok(())
}
pub fn wait_for_matcher(&mut self) -> Result<()> {
let timeout = std::time::Duration::from_secs(5);
let start = std::time::Instant::now();
let poll_interval = std::time::Duration::from_millis(10);
while !self.skim.app().matcher_control.stopped() {
if start.elapsed() > timeout {
return Err(color_eyre::eyre::eyre!("Timeout waiting for matcher to stop"));
}
std::thread::sleep(poll_interval);
}
std::thread::sleep(std::time::Duration::from_millis(50));
self.send(Event::Heartbeat)?;
self.tick()?;
let needs_preview = self.skim.app().options.preview.is_some() && self.skim.app().item_list.selected().is_some();
if needs_preview {
self.send(Event::RunPreview)?;
self.handle_remaining_events()?;
}
Ok(())
}
pub fn handle_remaining_events(&mut self) -> Result<()> {
self.tick()?;
let has_pending = match self.skim.app().preview.thread_handle {
Some(ref handle) => !handle.is_finished(),
None => false,
};
if !has_pending {
self.tick()?;
return Ok(());
}
let timeout = std::time::Duration::from_secs(2);
let start = std::time::Instant::now();
loop {
std::thread::sleep(std::time::Duration::from_millis(10));
self.tick()?;
let finished = self
.skim
.app()
.preview
.thread_handle
.as_ref()
.map(|h| h.is_finished())
.unwrap_or(true);
if finished {
self.tick()?;
return Ok(());
}
if start.elapsed() > timeout {
return Ok(());
}
}
}
pub fn heartbeat(&mut self) -> Result<()> {
self.send(Event::Heartbeat)?;
self.tick()
}
pub fn app_exit_code(&self) -> Option<i32> {
if !self.skim.app().should_quit {
return None;
}
let is_abort = self
.final_event
.as_ref()
.map(|event| !matches!(event, Event::Action(Action::Accept(_))))
.unwrap_or(true);
Some(if is_abort { 130 } else { 0 })
}
}
fn enter_sized_with_source(
options: SkimOptions,
width: u16,
height: u16,
source: Option<SkimItemReceiver>,
) -> Result<TestHarness> {
let backend = TestBackend::new(width, height);
let tui = Tui::new_with_height_and_backend(backend, Size::Percent(100))?;
let mut skim = Skim::<TestBackend>::init(options, source)?;
skim.init_tui_with(tui);
skim.start();
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
let mut harness = TestHarness {
skim,
runtime,
final_event: None,
};
harness.wait_for_reader_and_matcher()?;
Ok(harness)
}
pub fn enter_sized(options: SkimOptions, width: u16, height: u16) -> Result<TestHarness> {
enter_sized_with_source(options, width, height, None)
}
pub fn enter(options: SkimOptions) -> Result<TestHarness> {
enter_sized(options, 80, 24)
}
pub fn enter_default() -> Result<TestHarness> {
enter_sized(SkimOptions::default().build(), 80, 24)
}
pub fn enter_items<I, S>(items: I, options: SkimOptions) -> Result<TestHarness>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let text: String = items
.into_iter()
.map(|s| {
let mut line = s.as_ref().to_owned();
line.push('\n');
line
})
.collect();
let reader_opts = SkimItemReaderOption::from_options(&options);
let item_reader = SkimItemReader::new(reader_opts);
let rx = item_reader.of_bufread(Cursor::new(text));
enter_sized_with_source(options, 80, 24, Some(rx))
}
pub fn enter_bytes(bytes: &'static [u8], options: SkimOptions) -> Result<TestHarness> {
let reader_opts = SkimItemReaderOption::from_options(&options);
let item_reader = SkimItemReader::new(reader_opts);
let rx = item_reader.of_bufread(Cursor::new(bytes));
enter_sized_with_source(options, 80, 24, Some(rx))
}
pub fn enter_cmd(cmd: &str, options: SkimOptions) -> Result<TestHarness> {
let reader_opts = SkimItemReaderOption::from_options(&options);
let item_reader = SkimItemReader::new(reader_opts);
let rx = item_reader.of_bufread(std::io::BufReader::new(
std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()?
.stdout
.ok_or_else(|| color_eyre::eyre::eyre!("Failed to capture stdout"))?,
));
enter_sized_with_source(options, 80, 24, Some(rx))
}
pub fn enter_interactive(options: SkimOptions) -> Result<TestHarness> {
enter(options)
}
pub fn fmt_opts(opts: &[&str]) -> String {
opts.join(" ")
}
pub fn parse_options(args: &[&str]) -> SkimOptions {
let mut full_args = vec!["sk"];
full_args.extend(args);
SkimOptions::try_parse_from(full_args)
.expect("Failed to parse options")
.build()
}
#[macro_export]
macro_rules! snap {
($harness:ident, $desc:expr, $count:expr) => {{
$harness.prepare_snap()?;
let __buf = $harness.buffer_view();
let __cursor_pos = format!(
"cursor: ({}, {})",
$harness.skim.app().cursor_pos.1 + 1,
$harness.skim.app().cursor_pos.0 + 1
);
insta::with_settings!({
description => $desc,
snapshot_suffix => format!("{:03}", $count),
omit_expression => true,
}, {
insta::assert_snapshot!(__buf + &__cursor_pos);
});
}};
($harness:ident, $desc:expr) => {{
$harness.prepare_snap()?;
let __buf = $harness.buffer_view();
let __cursor_pos = format!(
"cursor: ({}, {})",
$harness.skim.app().cursor_pos.1 + 1,
$harness.skim.app().cursor_pos.0 + 1
);
insta::with_settings!({ description => $desc, omit_expression => true }, {
insta::assert_snapshot!(__buf + &__cursor_pos);
});
}};
($harness:ident) => {
$crate::snap!($harness, "")
};
}
#[macro_export]
macro_rules! insta_test {
($name:ident, [$($item:expr),* $(,)?], $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_items([$($item),*], options)?;
let __desc = format!(
"input: items [{}]\noptions: {}",
stringify!($($item),*),
$crate::common::insta::fmt_opts($options),
);
$crate::snap!(h, &__desc);
Ok(())
}
};
($name:ident, $items:expr, $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_items($items, options)?;
let __desc = format!(
"input: items {}\noptions: {}",
stringify!($items),
$crate::common::insta::fmt_opts($options),
);
$crate::snap!(h, &__desc);
Ok(())
}
};
($name:ident, @cmd $cmd:expr, $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_cmd($cmd, options)?;
let __desc = format!(
"input: cmd {}\noptions: {}",
stringify!($cmd),
$crate::common::insta::fmt_opts($options),
);
$crate::snap!(h, &__desc);
Ok(())
}
};
($name:ident, @bytes $bytes:expr, $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_bytes($bytes, options)?;
let __desc = format!(
"input: bytes {}\noptions: {}",
stringify!($bytes),
$crate::common::insta::fmt_opts($options),
);
$crate::snap!(h, &__desc);
Ok(())
}
};
($name:ident, @interactive, $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_interactive(options)?;
let __desc = format!(
"input: interactive\noptions: {}",
$crate::common::insta::fmt_opts($options),
);
$crate::snap!(h, &__desc);
Ok(())
}
};
($name:ident, $items:expr, $options:expr, { $($content:tt)* }) => {
#[test]
fn $name() -> color_eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_items($items, options)?;
let __base_desc = format!(
"input: items {}\noptions: {}",
stringify!($items),
$crate::common::insta::fmt_opts($options),
);
let mut __cmds: Vec<&'static str> = Vec::new();
let mut __snap_count: u32 = 0;
insta_test!(@expand h, __base_desc, __cmds, __snap_count; $($content)*);
Ok(())
}
};
($name:ident, @cmd $cmd:expr, $options:expr, { $($content:tt)* }) => {
#[test]
fn $name() -> color_eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_cmd($cmd, options)?;
let __base_desc = format!(
"input: cmd {}\noptions: {}",
stringify!($cmd),
$crate::common::insta::fmt_opts($options),
);
let mut __cmds: Vec<&'static str> = Vec::new();
let mut __snap_count: u32 = 0;
insta_test!(@expand h, __base_desc, __cmds, __snap_count; $($content)*);
Ok(())
}
};
($name:ident, @bytes $bytes:expr, $options:expr, { $($content:tt)* }) => {
#[test]
fn $name() -> color_eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_bytes($bytes, options)?;
let __base_desc = format!(
"input: bytes {}\noptions: {}",
stringify!($bytes),
$crate::common::insta::fmt_opts($options),
);
let mut __cmds: Vec<&'static str> = Vec::new();
let mut __snap_count: u32 = 0;
insta_test!(@expand h, __base_desc, __cmds, __snap_count; $($content)*);
Ok(())
}
};
($name:ident, @interactive, $options:expr, { $($content:tt)* }) => {
#[test]
fn $name() -> color_eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_interactive(options)?;
let __base_desc = format!(
"input: interactive\noptions: {}",
$crate::common::insta::fmt_opts($options),
);
let mut __cmds: Vec<&'static str> = Vec::new();
let mut __snap_count: u32 = 0;
insta_test!(@expand h, __base_desc, __cmds, __snap_count; $($content)*);
Ok(())
}
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; ) => {};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @snap; $($rest:tt)*) => {
{
$count += 1;
let __snap_desc = if $cmds.is_empty() {
$base.clone()
} else {
format!("{}\nafter:\n {}", $base, $cmds.join("\n "))
};
$crate::snap!($h, &__snap_desc, $count);
$cmds.clear();
}
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @char $c:expr ; $($rest:tt)*) => {
$cmds.push(concat!("@char ", stringify!($c)));
$h.char($c)?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @type $text:expr ; $($rest:tt)*) => {
$cmds.push(concat!("@type ", stringify!($text)));
$h.type_str($text)?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @action $action:ident ; $($rest:tt)*) => {
$cmds.push(concat!("@action ", stringify!($action)));
$h.action(skim::tui::event::Action::$action)?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @action $action:ident ($($args:tt)*) ; $($rest:tt)*) => {
$cmds.push(concat!("@action ", stringify!($action), "(", stringify!($($args)*), ")"));
$h.action(skim::tui::event::Action::$action($($args)*))?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @key $key:ident ; $($rest:tt)*) => {
$cmds.push(concat!("@key ", stringify!($key)));
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::$key,
crossterm::event::KeyModifiers::NONE
))?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @ctrl $key:ident ; $($rest:tt)*) => {
$cmds.push(concat!("@ctrl ", stringify!($key)));
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::$key,
crossterm::event::KeyModifiers::CONTROL
))?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @ctrl $key:literal ; $($rest:tt)*) => {
$cmds.push(concat!("@ctrl ", stringify!($key)));
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char($key),
crossterm::event::KeyModifiers::CONTROL
))?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @alt $key:ident ; $($rest:tt)*) => {
$cmds.push(concat!("@alt ", stringify!($key)));
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::$key,
crossterm::event::KeyModifiers::ALT
))?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @alt $key:literal ; $($rest:tt)*) => {
$cmds.push(concat!("@alt ", stringify!($key)));
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char($key),
crossterm::event::KeyModifiers::ALT
))?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @shift $key:ident ; $($rest:tt)*) => {
$cmds.push(concat!("@shift ", stringify!($key)));
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::$key,
crossterm::event::KeyModifiers::SHIFT
))?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @shift $key:literal ; $($rest:tt)*) => {
$cmds.push(concat!("@shift ", stringify!($key)));
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char($key),
crossterm::event::KeyModifiers::SHIFT
))?;
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @dbg; $($rest:tt)*) => {
$h.render()?;
println!("DBG buffer:\n{}", $h.buffer_view());
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @assert ( $assertion:expr ) ; $($rest:tt)*) => {
assert!(($assertion)(&$h));
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @exited $code:expr ; $($rest:tt)*) => {
assert_eq!(
$h.app_exit_code(),
Some($code),
"Expected app to exit with status code {}, but got {:?}",
$code,
$h.app_exit_code()
);
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
};
}