lotabout.skim/tests/common/insta.rs
LoricAndre a08f6ac9b3
feat!: interactive pty preview & concurrency optimizations (#952)
This PR has grown beyond its initial scope due to me over-optimizing everything, but it leads to:

    Paving the way for future actually interactive previews
    Consistently better performance than fzf in our bench thanks to thread and concurrency optimizations as well as the use of kanal for the items channels

Given the scope, I'm marking this as breaking because:

    setting wrap in the preview window layout disables the pty since we don't want to manipulate the raw buffer to word-wrap it manually
    kanal channels work slightly differently and might break library usage, even though switching to them did not require any modifications of the examples so it's unlikely that users will see anything break


* fix: force cwd for preview

* fix: correctly set cwd & kill pty child in the right order

* fix: use std threads & reopen new pty for each preview

* feat: use tui-term for displaying

* feat: scroll in pty

* fix: make nested skim previews work

* fix: clippy mistake

* feat: reactive preview triggering

* chore: generate completions & manpage

* chore: optimizations & thread cleanup

* chore: use kanal for faster channels

* fix: tests

* fix: only send items if the matcher hasn't been killed in the meantime (#947)

* tests: add coverage

* tests: fix bin path with coverage

* tests: upload tests to codecov

* chore: make pty opt-in through preview-window

* chore: generate completions & manpage

---------

Co-authored-by: Skim bot <skim-bot@skim-rs.github.io>
2026-02-09 21:43:04 +00:00

719 lines
24 KiB
Rust

use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::Arc;
use clap::Parser;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::TestBackend;
use skim::{
field::FieldRange,
helper::item::DefaultSkimItem,
prelude::*,
theme::ColorTheme,
tui::{App, Event, Tui, event::Action},
};
/// A test harness for running skim TUI tests with insta snapshots.
///
/// This struct wraps the TUI and App, providing an event-driven interface
/// that mirrors how the real application works. Events are sent via the
/// event channel and processed through the app's event loop.
pub struct TestHarness<'a> {
/// The TUI instance with TestBackend
pub tui: Tui<TestBackend>,
/// The application state
pub app: App<'a>,
/// Tokio runtime for async operations (preview commands, etc.)
pub runtime: tokio::runtime::Runtime,
/// The exit status of the last command executed (for @cmd tests)
pub exit_status: Option<i32>,
/// The final event that caused the app to quit (for determining exit code)
pub final_event: Option<Event>,
}
impl<'a> TestHarness<'a> {
/// Process all pending events from the event queue.
///
/// This is the core method that processes events just like the real event loop
/// in `lib.rs`. It drains the event_rx channel and calls `app.handle_event()`
/// for each event, mimicking the actual application behavior.
///
/// For `Event::Reload`, it executes the command and restarts the reader,
/// just like the main event loop does.
pub fn tick(&mut self) -> Result<()> {
// Process all pending events
while let Ok(event) = self.tui.event_rx.try_recv() {
self.process_event(event)?;
}
Ok(())
}
/// Process a single event through the app's event handler.
///
/// This handles special events like Reload that need extra processing
/// beyond what `app.handle_event()` does.
fn process_event(&mut self, event: Event) -> Result<()> {
// Handle reload event specially - this is what the main loop does
if let Event::Reload(ref new_cmd) = event {
// Clear items
self.app.item_pool.clear();
if !self.app.options.no_clear_if_empty {
self.app.item_list.clear();
}
// Run the command and add items
self.run_command_internal(new_cmd)?;
self.app.restart_matcher(true);
}
// Let the app handle the event (this may queue more events)
// Enter the runtime context so that tokio::spawn() calls work
let _guard = self.runtime.enter();
self.app.handle_event(&mut self.tui, &event)?;
// Track if app should quit and what the final event was
if self.app.should_quit && self.final_event.is_none() {
self.final_event = Some(event);
}
Ok(())
}
/// Send an event to the event queue.
///
/// This queues an event for processing. Call `tick()` to process queued events.
pub fn send(&mut self, event: Event) -> Result<()> {
self.tui.event_tx.send(event)?;
Ok(())
}
/// Send a key event and process it immediately.
///
/// This is the primary way to simulate user input. It:
/// 1. Sends the key event to the queue
/// 2. Processes all pending events (including any triggered by the key)
/// 3. For interactive mode, handles any reload commands
pub fn key(&mut self, key: KeyEvent) -> Result<()> {
self.send(Event::Key(key))?;
self.tick()?;
// Wait for matcher if items changed
if self.app.pending_matcher_restart || !self.app.matcher_control.stopped() {
self.wait_for_matcher()?;
}
Ok(())
}
/// Send a character key event.
pub fn char(&mut self, c: char) -> Result<()> {
self.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
}
/// Type a string, sending each character as a key event.
pub fn type_str(&mut self, s: &str) -> Result<()> {
for c in s.chars() {
self.char(c)?;
}
Ok(())
}
/// Send an action and process it immediately.
pub fn action(&mut self, action: Action) -> Result<()> {
self.send(Event::Action(action))?;
self.tick()?;
// Wait for matcher if items changed
if self.app.pending_matcher_restart || !self.app.matcher_control.stopped() {
self.wait_for_matcher()?;
}
Ok(())
}
/// Render the current app state to the terminal buffer.
pub fn render(&mut self) -> Result<()> {
self.tui.draw(|frame| {
frame.render_widget(&mut self.app, frame.area());
})?;
Ok(())
}
/// Get a string representation of the current buffer for snapshot testing.
pub fn buffer_view(&self) -> String {
self.tui.backend().to_string()
}
/// Prepare for taking a snapshot by waiting for preview and processing heartbeat.
///
/// This ensures the state is up-to-date before taking a snapshot.
/// Call `render()` and `buffer_view()` afterward to actually take the snapshot.
pub fn prepare_snap(&mut self) -> Result<()> {
// Send heartbeat first to trigger any debounced pending preview runs.
// The debounce logic in run_preview() sets pending_preview_run=true when
// a RunPreview is dropped. The Heartbeat handler checks this flag and
// spawns the preview. We need this to happen BEFORE wait_for_preview()
// so it can detect and wait for the spawned preview task.
self.send(Event::Heartbeat)?;
self.tick()?;
// Now wait for preview if configured
if self.app.options.preview.is_some() {
self.wait_for_preview()?;
}
self.render()?;
Ok(())
}
/// Take a snapshot of the current state.
///
/// NOTE: This method should NOT be called from test code directly because
/// insta will use the wrong file path for the snapshot. Use the snap! macro instead.
#[doc(hidden)]
pub fn snap(&mut self) -> Result<()> {
self.prepare_snap()?;
let buf = self.buffer_view();
let cursor_pos = format!("cursor: {}x{}", self.app.cursor_pos.0, self.app.cursor_pos.1);
insta::assert_snapshot!(buf + &cursor_pos);
Ok(())
}
/// Add items to the item pool and run the matcher.
pub fn add_items<I, S>(&mut self, items: I) -> Result<()>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
// Parse field ranges from options
let transform_fields: Vec<FieldRange> = self
.app
.options
.with_nth
.iter()
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
.collect();
let matching_fields: Vec<FieldRange> = self
.app
.options
.nth
.iter()
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
.collect();
let items: Vec<Arc<dyn SkimItem>> = items
.into_iter()
.enumerate()
.map(|(idx, s)| {
Arc::new(DefaultSkimItem::new(
s.into(),
self.app.options.ansi,
&transform_fields,
&matching_fields,
&self.app.options.delimiter,
idx,
)) as Arc<dyn SkimItem>
})
.collect();
self.app.handle_items(items);
self.app.restart_matcher(true);
self.wait_for_matcher()?;
Ok(())
}
/// Execute a shell command and add its output lines as items.
/// This is an internal method that doesn't restart the matcher.
fn run_command_internal(&mut self, cmd: &str) -> Result<()> {
let mut child = Command::new("sh")
.arg("-c")
.arg(cmd)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| color_eyre::eyre::eyre!("Failed to capture stdout"))?;
let reader = BufReader::new(stdout);
// Parse field ranges from options
let transform_fields: Vec<FieldRange> = self
.app
.options
.with_nth
.iter()
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
.collect();
let matching_fields: Vec<FieldRange> = self
.app
.options
.nth
.iter()
.filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None })
.collect();
let items: Vec<Arc<dyn SkimItem>> = reader
.lines()
.filter_map(|line| line.ok())
.enumerate()
.map(|(idx, s)| {
Arc::new(DefaultSkimItem::new(
s,
self.app.options.ansi,
&transform_fields,
&matching_fields,
&self.app.options.delimiter,
idx,
)) as Arc<dyn SkimItem>
})
.collect();
self.app.handle_items(items);
// Wait for the command to complete and capture its exit status
let status = child.wait()?;
self.exit_status = status.code();
Ok(())
}
/// Execute a shell command and add its output lines as items.
pub fn run_command(&mut self, cmd: &str) -> Result<()> {
self.run_command_internal(cmd)?;
self.app.restart_matcher(true);
self.wait_for_matcher()?;
Ok(())
}
/// Wait for matcher to complete processing.
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);
// Wait for matcher to complete
while !self.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);
}
// Give the background processing thread time to receive items
std::thread::sleep(std::time::Duration::from_millis(50));
// Render to consume processed items
self.render()?;
// Process heartbeat to update status counters
self.send(Event::Heartbeat)?;
self.tick()?;
// Manually trigger preview if configured and an item is selected
// Note: on_item_changed won't trigger automatically because the item was
// already selected during render, so prev_item == new_item
if self.app.options.preview.is_some() && self.app.item_list.selected().is_some() {
self.send(Event::RunPreview)?;
self.wait_for_preview()?;
}
Ok(())
}
/// Wait for preview to be ready.
pub fn wait_for_preview(&mut self) -> Result<()> {
// Process any queued events first (including RunPreview)
self.tick()?;
// Now check if there's a pending preview task
// If not, there's nothing to wait for
if let Some(ref handle) = self.app.preview.thread_handle {
if handle.is_finished() {
return Ok(());
}
} else {
return Ok(());
}
// Wait for preview to execute
// With multi-threaded runtime, spawned tasks run on background threads
let timeout = std::time::Duration::from_secs(2);
let start = std::time::Instant::now();
loop {
// Sleep to give background tasks time to execute
std::thread::sleep(std::time::Duration::from_millis(50));
// Try to process any pending events (including PreviewReady)
loop {
match self.tui.event_rx.try_recv() {
Ok(event) => {
let is_preview_ready = matches!(event, Event::PreviewReady);
self.process_event(event)?;
// If we got PreviewReady, render and return
if is_preview_ready {
self.render()?;
return Ok(());
}
}
Err(_) => break, // No more events
}
}
if start.elapsed() > timeout {
// Timeout - render anyway and return
self.render()?;
return Ok(());
}
}
}
/// Process a heartbeat event to update status counters.
pub fn heartbeat(&mut self) -> Result<()> {
self.send(Event::Heartbeat)?;
self.tick()
}
/// Get the exit code that skim would return based on the final event.
///
/// This mimics the logic in `bin/main.rs`:
/// - 130 if the app aborted (Ctrl+C, Ctrl+D, Esc, etc.)
/// - 0 if the app accepted (Enter)
/// - None if the app hasn't quit yet
pub fn app_exit_code(&self) -> Option<i32> {
if !self.app.should_quit {
return None;
}
// Check if the final event was an Accept action
// This matches the logic in lib.rs:560
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 })
}
}
// ============================================================================
// Factory functions
// ============================================================================
/// Initialize a test harness with the given options and dimensions.
pub fn enter_sized<'a>(options: SkimOptions, width: u16, height: u16) -> Result<TestHarness<'a>> {
let backend = TestBackend::new(width, height);
let tui = Tui::new_for_test(backend)?;
let theme = Arc::new(ColorTheme::init_from_options(&options));
let cmd = options.cmd.clone().unwrap_or_default();
let app = App::from_options(options, theme, cmd);
// Create a multi-threaded tokio runtime for async operations (preview commands, etc.)
// We use multi-threaded so spawned tasks can execute on background threads
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
Ok(TestHarness {
tui,
app,
runtime,
exit_status: None,
final_event: None,
})
}
/// Initialize a test harness with default dimensions (80x24).
pub fn enter<'a>(options: SkimOptions) -> Result<TestHarness<'a>> {
enter_sized(options, 80, 24)
}
/// Initialize a test harness with default options.
pub fn enter_default<'a>() -> Result<TestHarness<'a>> {
enter_sized(SkimOptions::default().build(), 80, 24)
}
/// Initialize a test harness with pre-loaded items.
pub fn enter_items<'a, I, S>(items: I, options: SkimOptions) -> Result<TestHarness<'a>>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut harness = enter(options)?;
harness.add_items(items)?;
Ok(harness)
}
/// Initialize a test harness with command output as items.
pub fn enter_cmd<'a>(cmd: &str, options: SkimOptions) -> Result<TestHarness<'a>> {
let mut harness = enter(options)?;
harness.run_command(cmd)?;
Ok(harness)
}
/// Initialize a test harness for interactive mode.
///
/// This runs the initial command (with empty query) and sets up the harness.
pub fn enter_interactive<'a>(options: SkimOptions) -> Result<TestHarness<'a>> {
let mut harness = enter(options)?;
// Run initial command with current (empty) query
if let Some(ref cmd_template) = harness.app.options.cmd.clone() {
let expanded_cmd = harness.app.expand_cmd(&cmd_template, true);
harness.run_command(&expanded_cmd)?;
}
Ok(harness)
}
/// Parse SkimOptions from CLI-style arguments.
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()
}
// ============================================================================
// Macros
// ============================================================================
#[macro_export]
macro_rules! snap {
($harness:ident) => {
$harness.prepare_snap()?;
let buf = $harness.buffer_view();
let cursor_pos = format!(
"cursor: ({}, {})",
$harness.app.cursor_pos.1 + 1,
$harness.app.cursor_pos.0 + 1
);
insta::assert_snapshot!(buf + &cursor_pos);
};
}
/// Macro for writing compact insta snapshot tests.
///
/// # Usage
///
/// ## Input syntax:
/// - `["a", "b", "c"]` - items array
/// - `@cmd "seq 1 100"` - command output as items
/// - `@interactive` - interactive mode with `--cmd`
///
/// ## Basic usage (just takes a snapshot):
/// ```ignore
/// insta_test!(test_name, ["item1", "item2"], &["--opts"]);
/// ```
///
/// ## DSL usage (with commands):
/// ```ignore
/// insta_test!(test_name, ["a", "b", "c"], &["--multi"], {
/// @snap; // Take snapshot
/// @char 'f'; // Send single character
/// @type "foo"; // Type string
/// @action Down(1); // Send action
/// @key Enter; // Send special key
/// @exited 0; // Assert command exited with status code 0
/// });
/// ```
#[macro_export]
macro_rules! insta_test {
// Simple variant with items array - just snapshot
($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)?;
$crate::snap!(h);
Ok(())
}
};
// Simple variant with @cmd - just snapshot
($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)?;
$crate::snap!(h);
Ok(())
}
};
// Simple variant with @interactive - just snapshot
($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)?;
$crate::snap!(h);
Ok(())
}
};
// DSL variant with items array
($name:ident, [$($item: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([$($item),*], options)?;
insta_test!(@expand h; $($content)*);
Ok(())
}
};
// DSL variant with @cmd
($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)?;
insta_test!(@expand h; $($content)*);
Ok(())
}
};
// DSL variant with @interactive
($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)?;
insta_test!(@expand h; $($content)*);
Ok(())
}
};
// Token processing rules
(@expand $h:ident; ) => {};
// @snap - take snapshot
(@expand $h:ident; @snap; $($rest:tt)*) => {
$crate::snap!($h);
insta_test!(@expand $h; $($rest)*);
};
// @char - send single character
(@expand $h:ident; @char $c:expr ; $($rest:tt)*) => {
$h.char($c)?;
insta_test!(@expand $h; $($rest)*);
};
// @type - type a string
(@expand $h:ident; @type $text:expr ; $($rest:tt)*) => {
$h.type_str($text)?;
insta_test!(@expand $h; $($rest)*);
};
// @action - send an action (e.g., @action Down(1); or @action BackwardChar;)
(@expand $h:ident; @action $action:ident ; $($rest:tt)*) => {
$h.action(skim::tui::event::Action::$action)?;
insta_test!(@expand $h; $($rest)*);
};
// @action with parenthesized args (e.g., @action Down(1);)
(@expand $h:ident; @action $action:ident ($($args:tt)*) ; $($rest:tt)*) => {
$h.action(skim::tui::event::Action::$action($($args)*))?;
insta_test!(@expand $h; $($rest)*);
};
// @key - send a special key (Enter, Escape, Tab, etc.)
(@expand $h:ident; @key $key:ident ; $($rest:tt)*) => {
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::$key,
crossterm::event::KeyModifiers::NONE
))?;
insta_test!(@expand $h; $($rest)*);
};
// @ctrl - send a key with Ctrl modifier
(@expand $h:ident; @ctrl $key:ident ; $($rest:tt)*) => {
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::$key,
crossterm::event::KeyModifiers::CONTROL
))?;
insta_test!(@expand $h; $($rest)*);
};
// @ctrl with char
(@expand $h:ident; @ctrl $key:literal ; $($rest:tt)*) => {
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char($key),
crossterm::event::KeyModifiers::CONTROL
))?;
insta_test!(@expand $h; $($rest)*);
};
// @alt - send a key with Alt modifier
(@expand $h:ident; @alt $key:ident ; $($rest:tt)*) => {
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::$key,
crossterm::event::KeyModifiers::ALT
))?;
insta_test!(@expand $h; $($rest)*);
};
// @alt with char
(@expand $h:ident; @alt $key:literal ; $($rest:tt)*) => {
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char($key),
crossterm::event::KeyModifiers::ALT
))?;
insta_test!(@expand $h; $($rest)*);
};
// @shift - send a key with Shift modifier
(@expand $h:ident; @shift $key:ident ; $($rest:tt)*) => {
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::$key,
crossterm::event::KeyModifiers::SHIFT
))?;
insta_test!(@expand $h; $($rest)*);
};
// @shift with char
(@expand $h:ident; @shift $key:literal ; $($rest:tt)*) => {
$h.key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char($key),
crossterm::event::KeyModifiers::SHIFT
))?;
insta_test!(@expand $h; $($rest)*);
};
// @dbg - debug print current buffer
(@expand $h:ident; @dbg; $($rest:tt)*) => {
$h.render()?;
println!("DBG buffer:\n{}", $h.buffer_view());
insta_test!(@expand $h; $($rest)*);
};
// @assert - run an assertion closure
// Pass a closure that takes the harness as parameter
// Usage: @assert(|h| h.app.should_quit);
// @assert(|h| h.app.item_list.selected().unwrap().text() == "1");
(@expand $h:ident; @assert ( $assertion:expr ) ; $($rest:tt)*) => {
assert!(($assertion)(&$h));
insta_test!(@expand $h; $($rest)*);
};
// @exited - assert that the app would exit with a specific status code
// Usage: @exited 0; // Assert successful exit (Accept)
// @exited 130; // Assert abort (Ctrl+C, Ctrl+D, Esc)
(@expand $h: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; $($rest)*);
};
}