mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 07:16:23 -04:00
* Shrink binary: trim image decoders and swap color-eyre for eyre Two dependency changes that cut the default `sk` binary from 13.6 MiB to 8.55 MiB (-5.06 MiB, -37%) with no loss of core functionality: - image: build the `image` crate with only the common decoders (png, jpeg, gif, webp) instead of its full default format set, and drop ratatui-image's `image-defaults`. This removes AVIF encoding (ravif, avif-serialize), OpenEXR (exr), TIFF, QOI and other decoders that are irrelevant to terminal image previews. Previewing those formats now falls back to the normal command preview. - error handling: replace color-eyre with plain eyre. color-eyre only provided colored panic/error backtraces; skim used none of its Section/Help extension APIs. This drops the backtrace/gimli/addr2line/ color-spantrace stack. `color_eyre::install()` is no longer needed. Tests, benches and examples are migrated from color_eyre to eyre so the crate is fully removed from the dependency graph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BQtxeCS4gM7dumghqmNgST * chore: fmt * docs: ARCHITECTURE.md * chore(flake): add cargo-bloat --------- Co-authored-by: Claude <noreply@anthropic.com>
986 lines
38 KiB
Rust
986 lines
38 KiB
Rust
use std::io::Cursor;
|
|
|
|
use clap::Parser;
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
|
|
use eyre::Result;
|
|
use ratatui::backend::TestBackend;
|
|
use skim::prelude::*;
|
|
use skim::tui::event::Action;
|
|
use skim::tui::{Event, Size, Tui};
|
|
use skim::{Skim, SkimItemReceiver};
|
|
|
|
/// A test harness for running skim TUI tests with insta snapshots.
|
|
///
|
|
/// This struct wraps a [`Skim<TestBackend>`] instance, providing a synchronous,
|
|
/// 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.
|
|
///
|
|
/// The harness reuses as much of the production code path as possible:
|
|
/// - Items are loaded through the same `Reader` + `SkimItemReader` pipeline as
|
|
/// the real application
|
|
/// - Reload events use `Skim::handle_reload()`, the same logic as the production
|
|
/// event loop
|
|
/// - Reader completion is checked via `Skim::check_reader()`, identical to
|
|
/// production
|
|
pub struct TestHarness {
|
|
/// The Skim instance backed by a TestBackend for snapshot testing.
|
|
pub skim: Skim<TestBackend>,
|
|
/// Tokio runtime for async operations (preview commands, etc.)
|
|
pub runtime: tokio::runtime::Runtime,
|
|
/// The final event that caused the app to quit (for determining exit code)
|
|
pub final_event: Option<Event>,
|
|
}
|
|
|
|
impl TestHarness {
|
|
/// 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 delegates to `Skim::handle_reload()` — the same
|
|
/// method used by the production event loop.
|
|
pub fn tick(&mut self) -> Result<()> {
|
|
// Drain-and-process in a loop so events queued during processing
|
|
// are picked up on the next iteration.
|
|
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(())
|
|
}
|
|
|
|
/// Process a single event through the same logic as the production event loop.
|
|
///
|
|
/// `Event::Reload` is handled via `Skim::handle_reload()` — the exact same
|
|
/// method used in production. All other events are forwarded to
|
|
/// `app.handle_event()`.
|
|
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 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();
|
|
let (app, tui) = self.skim.app_and_tui();
|
|
app.handle_event(tui, &event)?;
|
|
}
|
|
|
|
// Check reader status, just like the production event loop
|
|
self.skim.check_reader();
|
|
|
|
// Track if app should quit and what the final event was
|
|
if self.skim.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.skim.tui_mut().event_tx.try_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. Waits for reader (if running) and matcher to complete
|
|
pub fn key(&mut self, key: KeyEvent) -> Result<()> {
|
|
self.send(Event::Key(key))?;
|
|
self.tick()?;
|
|
self.wait_for_completion()?;
|
|
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()?;
|
|
self.wait_for_completion()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Send a mouse event and process any resulting events immediately.
|
|
pub fn mouse(&mut self, mouse: MouseEvent) -> Result<()> {
|
|
self.send(Event::Mouse(mouse))?;
|
|
self.handle_remaining_events()
|
|
}
|
|
|
|
/// Wait for any in-flight reader and matcher to complete.
|
|
///
|
|
/// If the reader is still running (e.g., after a reload in interactive mode),
|
|
/// waits for it to finish first. Then waits for the matcher if it needs to run.
|
|
///
|
|
/// If a debounced matcher restart is pending (e.g., because the query changed
|
|
/// within the 50ms debounce window), this forces the restart immediately so
|
|
/// tests don't see stale results.
|
|
fn wait_for_completion(&mut self) -> Result<()> {
|
|
// If a debounced matcher restart is pending, force it now.
|
|
// In production, the Heartbeat event would trigger this, but in tests
|
|
// we don't have a continuous heartbeat timer.
|
|
if self.skim.app().pending_matcher_restart {
|
|
self.skim.app_mut().restart_matcher(true);
|
|
}
|
|
|
|
// If the reader is running (not done), wait for it + matcher
|
|
if !self.skim.reader_done() {
|
|
self.wait_for_reader_and_matcher()?;
|
|
} else {
|
|
// Always wait for the matcher to ensure results are consumed.
|
|
// Even if stopped() is already true, wait_for_matcher will
|
|
// render and process heartbeat to consume processed_items.
|
|
self.wait_for_matcher()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Get a string representation of the current buffer for snapshot testing.
|
|
pub fn buffer_view(&self) -> String {
|
|
self.skim.tui_ref().backend().to_string()
|
|
}
|
|
|
|
/// Get a representation of the buffer's *styling* for snapshot testing.
|
|
///
|
|
/// `buffer_view()` only captures cell text, so it cannot verify color. This
|
|
/// view instead lists every run of consecutive cells that share a
|
|
/// non-default style, as `(row, start..end) "text" fg=… bg=… mod=…`. Cells
|
|
/// with the default style (`fg=Reset bg=Reset`, no modifier) are omitted so
|
|
/// the snapshot stays focused on what is actually colored.
|
|
pub fn color_view(&self) -> String {
|
|
use ratatui::style::{Color, Modifier};
|
|
|
|
let buffer = self.skim.tui_ref().backend().buffer();
|
|
let area = buffer.area;
|
|
let cell_at = |x: u16, y: u16| buffer.cell((x, y)).expect("cell within buffer area");
|
|
let is_default = |fg: Color, bg: Color, md: Modifier| fg == Color::Reset && bg == Color::Reset && md.is_empty();
|
|
|
|
let mut out = String::new();
|
|
for y in 0..area.height {
|
|
let mut x = 0;
|
|
while x < area.width {
|
|
let cell = cell_at(x, y);
|
|
let (fg, bg, md) = (cell.fg, cell.bg, cell.modifier);
|
|
if is_default(fg, bg, md) {
|
|
x += 1;
|
|
continue;
|
|
}
|
|
// Merge the run of cells sharing this exact style.
|
|
let start = x;
|
|
let mut text = String::new();
|
|
while x < area.width {
|
|
let c = cell_at(x, y);
|
|
if c.fg != fg || c.bg != bg || c.modifier != md {
|
|
break;
|
|
}
|
|
text.push_str(c.symbol());
|
|
x += 1;
|
|
}
|
|
let mut attrs = Vec::new();
|
|
if fg != Color::Reset {
|
|
attrs.push(format!("fg={fg:?}"));
|
|
}
|
|
if bg != Color::Reset {
|
|
attrs.push(format!("bg={bg:?}"));
|
|
}
|
|
if !md.is_empty() {
|
|
attrs.push(format!("mod={md:?}"));
|
|
}
|
|
out.push_str(&format!("({y}, {start}..{x}) {text:?} {}\n", attrs.join(" ")));
|
|
}
|
|
}
|
|
if out.is_empty() {
|
|
out.push_str("(no styled cells)\n");
|
|
}
|
|
out
|
|
}
|
|
|
|
/// 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()?;
|
|
|
|
self.handle_remaining_events()?;
|
|
|
|
// Force a final render so that any state changes (e.g. PreviewReady) that
|
|
// were processed inside handle_remaining_events are reflected in the buffer
|
|
// before we take the snapshot. We bypass the frame-rate throttle by sending
|
|
// Render directly instead of relying on the Heartbeat path.
|
|
self.send(Event::Render)?;
|
|
self.tick()?;
|
|
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.skim.app().cursor_pos.0,
|
|
self.skim.app().cursor_pos.1
|
|
);
|
|
insta::assert_snapshot!(buf + &cursor_pos);
|
|
Ok(())
|
|
}
|
|
|
|
/// Wait for the reader to finish producing items and the matcher to complete.
|
|
///
|
|
/// This polls `Skim::reader_done()` and `Skim::check_reader()` — the same
|
|
/// methods used by the production event loop — until the reader has finished,
|
|
/// then waits for the matcher to process all items.
|
|
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);
|
|
|
|
// Wait for reader to finish
|
|
while !self.skim.reader_done() {
|
|
if start.elapsed() > timeout {
|
|
return Err(eyre::eyre!("Timeout waiting for reader to finish"));
|
|
}
|
|
// Check reader status (may restart matcher)
|
|
self.skim.check_reader();
|
|
std::thread::sleep(poll_interval);
|
|
}
|
|
|
|
// Final check to restart matcher with remaining items
|
|
self.skim.check_reader();
|
|
|
|
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.skim.app().matcher_control.stopped() {
|
|
if start.elapsed() > timeout {
|
|
return Err(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));
|
|
|
|
// 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
|
|
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(())
|
|
}
|
|
|
|
/// Wait for preview to be ready.
|
|
pub fn handle_remaining_events(&mut self) -> Result<()> {
|
|
// Process any queued events first (including RunPreview)
|
|
self.tick()?;
|
|
|
|
let debounce_timeout = std::time::Duration::from_secs(2);
|
|
let debounce_start = std::time::Instant::now();
|
|
while self.skim.app().pending_preview_run {
|
|
if debounce_start.elapsed() > debounce_timeout {
|
|
return Err(eyre::eyre!("Timeout waiting for debounced preview to run"));
|
|
}
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
self.send(Event::Heartbeat)?;
|
|
self.tick()?;
|
|
}
|
|
|
|
// If there's no preview task running, nothing to wait for
|
|
let has_pending = match self.skim.app().preview.thread_handle {
|
|
Some(ref handle) => !handle.is_finished(),
|
|
None => false,
|
|
};
|
|
|
|
if !has_pending {
|
|
// Thread is already done (or was never started). Drain any events it may
|
|
// have sent (e.g. PreviewReady) that arrived after our initial tick().
|
|
self.tick()?;
|
|
return Ok(());
|
|
}
|
|
|
|
// Wait for the preview thread to finish, then drain its events.
|
|
let timeout = std::time::Duration::from_secs(2);
|
|
let start = std::time::Instant::now();
|
|
|
|
loop {
|
|
// Sleep to give the background thread time to make progress
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
|
|
// Drain and process any events (including PreviewReady)
|
|
self.tick()?;
|
|
|
|
// Exit as soon as the thread is done and we have processed its events
|
|
let finished = self
|
|
.skim
|
|
.app()
|
|
.preview
|
|
.thread_handle
|
|
.as_ref()
|
|
.map(|h| h.is_finished())
|
|
.unwrap_or(true);
|
|
if finished {
|
|
// One final drain to catch any events emitted right at thread exit
|
|
self.tick()?;
|
|
return Ok(());
|
|
}
|
|
|
|
if start.elapsed() > timeout {
|
|
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.skim.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, dimensions, and optional item source.
|
|
///
|
|
/// Uses [`Skim::init`] for the core initialization (App, Reader, theme, etc.)
|
|
/// and [`Skim::init_tui_with`] to inject a [`TestBackend`].
|
|
/// Then calls [`Skim::start`] to begin the reader and matcher — the same
|
|
/// production code path used by the real application.
|
|
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);
|
|
|
|
// Start the reader and matcher — the same call the production binary makes
|
|
skim.start();
|
|
|
|
// Create a multi-threaded tokio runtime for async operations (preview commands, etc.)
|
|
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
|
|
|
|
let mut harness = TestHarness {
|
|
skim,
|
|
runtime,
|
|
final_event: None,
|
|
};
|
|
|
|
// Wait for the reader to finish and matcher to complete
|
|
harness.wait_for_reader_and_matcher()?;
|
|
|
|
Ok(harness)
|
|
}
|
|
|
|
/// Initialize a test harness with the given options and dimensions.
|
|
pub fn enter_sized(options: SkimOptions, width: u16, height: u16) -> Result<TestHarness> {
|
|
enter_sized_with_source(options, width, height, None)
|
|
}
|
|
|
|
/// Initialize a test harness with default dimensions (80x24).
|
|
pub fn enter(options: SkimOptions) -> Result<TestHarness> {
|
|
enter_sized(options, 80, 24)
|
|
}
|
|
|
|
/// Initialize a test harness with default options.
|
|
pub fn enter_default() -> Result<TestHarness> {
|
|
enter_sized(SkimOptions::default().build(), 80, 24)
|
|
}
|
|
|
|
/// Initialize a test harness with pre-loaded items.
|
|
///
|
|
/// Items are fed through the production `Reader` + `SkimItemReader` pipeline
|
|
/// by converting them to a newline-separated byte stream and using
|
|
/// `SkimItemReader::of_bufread()`.
|
|
pub fn enter_items<I, S>(items: I, options: SkimOptions) -> Result<TestHarness>
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<str>,
|
|
{
|
|
// Build a newline-terminated string from items and feed through SkimItemReader.
|
|
// Each item must end with '\n' so the reader produces one item per entry,
|
|
// including empty-string items like "".
|
|
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))
|
|
}
|
|
|
|
/// Initialize a test harness with raw bytes as input.
|
|
///
|
|
/// Feeds bytes directly through the `SkimItemReader` pipeline without spawning
|
|
/// a subprocess. Useful for inputs containing NUL bytes (e.g. `--read0` tests)
|
|
/// where the byte sequence cannot be expressed as a simple string.
|
|
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))
|
|
}
|
|
|
|
/// Initialize a test harness with command output as items.
|
|
///
|
|
/// The command is executed through the production `Reader` + `SkimItemReader`
|
|
/// pipeline, identical to how the real application works.
|
|
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(|| eyre::eyre!("Failed to capture stdout"))?,
|
|
));
|
|
|
|
enter_sized_with_source(options, 80, 24, Some(rx))
|
|
}
|
|
|
|
/// Initialize a test harness for interactive mode.
|
|
///
|
|
/// Uses [`Skim::start`] which already handles interactive mode correctly:
|
|
/// it expands the command template with the initial query and starts the
|
|
/// reader pipeline.
|
|
pub fn enter_interactive(options: SkimOptions) -> Result<TestHarness> {
|
|
// Skim::init() computes initial_cmd for interactive mode,
|
|
// and Skim::start() kicks off the reader with it.
|
|
// No special-casing needed here.
|
|
enter(options)
|
|
}
|
|
|
|
/// Format a slice of CLI-style option strings as a single space-separated string.
|
|
///
|
|
/// Used by `insta_test!` to build snapshot descriptions. Accepting `&[&str]`
|
|
/// explicitly avoids the type-inference failure that occurs when the macro
|
|
/// passes an un-typed empty slice literal (`&[]`) to `.join()`.
|
|
pub fn fmt_opts(opts: &[&str]) -> String {
|
|
opts.join(" ")
|
|
}
|
|
|
|
/// 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 {
|
|
// With description and a 1-based counter — uses `snapshot_suffix` so that
|
|
// insta names the file `test_name@001.snap`, `test_name@002.snap`, …
|
|
// Files then sort correctly in `cargo insta review`.
|
|
($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);
|
|
});
|
|
}};
|
|
// With description only (simple/single-snapshot variants) — no suffix, so
|
|
// the file is named `test_name.snap` as before.
|
|
($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);
|
|
});
|
|
}};
|
|
// Backward-compat form — no description, no suffix.
|
|
($harness:ident) => {
|
|
$crate::snap!($harness, "")
|
|
};
|
|
}
|
|
|
|
/// Like [`snap!`], but snapshots the buffer's *styling* (via
|
|
/// [`TestHarness::color_view`]) instead of its text. Color snapshots live in
|
|
/// their own files (`{test}@color{NNN}.snap`) so they never collide with the
|
|
/// text snapshots taken by `snap!` / `@snap`.
|
|
#[macro_export]
|
|
macro_rules! snap_color {
|
|
($harness:ident, $desc:expr, $count:expr) => {{
|
|
$harness.prepare_snap()?;
|
|
let __cv = $harness.color_view();
|
|
insta::with_settings!({
|
|
description => $desc,
|
|
snapshot_suffix => format!("color{:03}", $count),
|
|
omit_expression => true,
|
|
}, {
|
|
insta::assert_snapshot!(__cv);
|
|
});
|
|
}};
|
|
($harness:ident, $desc:expr) => {{
|
|
$harness.prepare_snap()?;
|
|
let __cv = $harness.color_view();
|
|
insta::with_settings!({ description => $desc, omit_expression => true }, {
|
|
insta::assert_snapshot!(__cv);
|
|
});
|
|
}};
|
|
}
|
|
|
|
/// 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
|
|
/// @mouse(|h| mouse_down(h, 1)); // Send mouse event
|
|
/// @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() -> 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(())
|
|
}
|
|
};
|
|
|
|
// Simple variant with items expression (identifier or expression) - just snapshot
|
|
($name:ident, $items:expr, $options:expr) => {
|
|
#[test]
|
|
fn $name() -> 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(())
|
|
}
|
|
};
|
|
|
|
// Simple variant with @cmd - just snapshot
|
|
($name:ident, @cmd $cmd:expr, $options:expr) => {
|
|
#[test]
|
|
fn $name() -> 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(())
|
|
}
|
|
};
|
|
|
|
// Simple variant with @bytes - just snapshot
|
|
($name:ident, @bytes $bytes:expr, $options:expr) => {
|
|
#[test]
|
|
fn $name() -> 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(())
|
|
}
|
|
};
|
|
|
|
// Simple variant with @interactive - just snapshot
|
|
($name:ident, @interactive, $options:expr) => {
|
|
#[test]
|
|
fn $name() -> 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(())
|
|
}
|
|
};
|
|
|
|
// DSL variant with items expression (identifier or expression)
|
|
($name:ident, $items:expr, $options:expr, { $($content:tt)* }) => {
|
|
#[test]
|
|
fn $name() -> 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(())
|
|
}
|
|
};
|
|
|
|
// DSL variant with @cmd
|
|
($name:ident, @cmd $cmd:expr, $options:expr, { $($content:tt)* }) => {
|
|
#[test]
|
|
fn $name() -> 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(())
|
|
}
|
|
};
|
|
|
|
// DSL variant with @bytes
|
|
($name:ident, @bytes $bytes:expr, $options:expr, { $($content:tt)* }) => {
|
|
#[test]
|
|
fn $name() -> 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(())
|
|
}
|
|
};
|
|
|
|
// DSL variant with @interactive
|
|
($name:ident, @interactive, $options:expr, { $($content:tt)* }) => {
|
|
#[test]
|
|
fn $name() -> 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(())
|
|
}
|
|
};
|
|
|
|
// Token processing rules
|
|
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; ) => {};
|
|
|
|
// @snap - increment counter, build description, snapshot with sorted suffix, clear
|
|
(@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)*);
|
|
};
|
|
|
|
// @snap_color - like @snap, but captures cell styling (color) into its own
|
|
// snapshot file. Shares the snapshot counter with @snap; the description is
|
|
// NOT cleared so a following @snap reflects the same since-last-snap commands.
|
|
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @snap_color; $($rest:tt)*) => {
|
|
{
|
|
$count += 1;
|
|
let __snap_desc = if $cmds.is_empty() {
|
|
$base.clone()
|
|
} else {
|
|
format!("{}\nafter:\n {}", $base, $cmds.join("\n "))
|
|
};
|
|
$crate::snap_color!($h, &__snap_desc, $count);
|
|
}
|
|
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
|
|
};
|
|
|
|
// @char - send single character
|
|
(@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)*);
|
|
};
|
|
|
|
// @type - type a string
|
|
(@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)*);
|
|
};
|
|
|
|
// @action - send an action (e.g., @action Down(1); or @action BackwardChar;)
|
|
(@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)*);
|
|
};
|
|
|
|
// @action with parenthesized args (e.g., @action Down(1);)
|
|
(@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)*);
|
|
};
|
|
|
|
// @mouse - build and send a mouse event from the current harness state
|
|
(@expand $h:ident, $base:ident, $cmds:ident, $count:ident; @mouse ( $mouse:expr ) ; $($rest:tt)*) => {
|
|
$cmds.push(concat!("@mouse(", stringify!($mouse), ")"));
|
|
let __mouse = ($mouse)(&$h);
|
|
$h.mouse(__mouse)?;
|
|
insta_test!(@expand $h, $base, $cmds, $count; $($rest)*);
|
|
};
|
|
|
|
// @key - send a special key (Enter, Escape, Tab, etc.)
|
|
(@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)*);
|
|
};
|
|
|
|
// @ctrl - send a key with Ctrl modifier
|
|
(@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)*);
|
|
};
|
|
|
|
// @ctrl with char
|
|
(@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)*);
|
|
};
|
|
|
|
// @alt - send a key with Alt modifier
|
|
(@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)*);
|
|
};
|
|
|
|
// @alt with char
|
|
(@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)*);
|
|
};
|
|
|
|
// @shift - send a key with Shift modifier
|
|
(@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)*);
|
|
};
|
|
|
|
// @shift with char
|
|
(@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)*);
|
|
};
|
|
|
|
// @dbg - debug print current buffer (not recorded in command history)
|
|
(@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)*);
|
|
};
|
|
|
|
// @assert - run an assertion closure
|
|
// Pass a closure that takes the harness as parameter
|
|
// Usage: @assert(|h| h.skim.app().should_quit);
|
|
// @assert(|h| h.skim.app().item_list.selected().unwrap().text() == "1");
|
|
(@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)*);
|
|
};
|
|
|
|
// @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, $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)*);
|
|
};
|
|
}
|