lotabout.skim/tests/cli.rs
VXNCXNX 94022921a3
fix(field): an out-of-range field index silently matches field 1 (#1155)
* fix(field): saturate out-of-range field indices to i32 bounds

Replace unwrap_or(1)/unwrap_or(-1) with saturation to i32 bounds so out-of-range indices don't silently become field 1. Affects --nth, --with-nth, --hide-nth and {N} in --output-format.

* test(field): assert exit status and stderr for the output-format cases

Per review feedback: an empty stdout alone could pass if the placeholder
errored out instead of rendering empty.

---------

Co-authored-by: VXNCXNX <VXNCXNX@users.noreply.github.com>
2026-08-16 11:07:20 +02:00

301 lines
11 KiB
Rust

//! Non-interactive CLI integration tests.
//!
//! These spawn the real `sk` binary in modes that exit without a TTY (filter
//! mode, shell-completion, man-page, and the various `--print-*` / output
//! flags). Because the binary is the instrumented `llvm-cov-target` build under
//! coverage, they exercise `bin/main.rs` and `skim.rs`'s non-interactive paths.
//!
//! The binary is spawned directly (no shell), so the tests are cross-platform.
//! `env_clear()` is intentionally NOT used so that `LLVM_PROFILE_FILE` (set by
//! cargo-llvm-cov) is inherited by the child and its coverage is recorded; only
//! the `SKIM_*` vars are removed explicitly.
#![allow(missing_docs, clippy::pedantic)]
#[allow(dead_code)]
mod common;
use std::io::Write;
use std::process::{Command, Stdio};
use common::SK;
fn sk_bin() -> &'static str {
SK
}
/// Spawn the binary with explicit argv and env, feeding `pipe_input` on stdin.
/// Returns `(exit_code, stdout, stderr)`.
fn run_sk_argv(pipe_input: &str, argv: &[&str], envs: &[(&str, &str)]) -> (Option<i32>, String, String) {
let mut cmd = Command::new(sk_bin());
cmd.args(argv)
.env_remove("SKIM_DEFAULT_OPTIONS")
.env_remove("SKIM_DEFAULT_COMMAND")
.env_remove("SKIM_OPTIONS_FILE")
.stdin(if pipe_input.is_empty() {
Stdio::null()
} else {
Stdio::piped()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in envs {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("failed to spawn sk");
if !pipe_input.is_empty() {
// The callers write escapes (`\n`) the way `printf` once interpreted them.
let input = pipe_input
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\0", "\0");
child
.stdin
.take()
.expect("stdin piped")
.write_all(input.as_bytes())
.expect("write stdin");
}
let out = child.wait_with_output().expect("failed to wait on sk");
(
out.status.code(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
/// Convenience wrapper: tokenize a space-separated `args` string (shell-style)
/// and run with no extra env. Use [`run_sk_argv`] directly when an argument may
/// contain spaces (e.g. a temp-file path).
fn run_sk(pipe_input: &str, args: &str) -> (Option<i32>, String, String) {
let argv = shlex::split(args).expect("args should tokenize");
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
run_sk_argv(pipe_input, &refs, &[])
}
#[test]
fn filter_mode_prints_matches() {
// `-f` runs filter mode: no TUI, matched lines printed to stdout, exit 0.
let (code, stdout, _) = run_sk("apple\\nbanana\\ncherry", "-f a");
assert_eq!(code, Some(0));
// 'apple' and 'banana' contain 'a'; 'cherry' does not.
assert!(stdout.contains("apple"));
assert!(stdout.contains("banana"));
assert!(!stdout.contains("cherry"));
}
#[test]
fn filter_mode_empty_query_matches_all() {
let (code, stdout, _) = run_sk("one\\ntwo\\nthree", "-f ''");
assert_eq!(code, Some(0));
assert!(stdout.contains("one"));
assert!(stdout.contains("two"));
assert!(stdout.contains("three"));
}
#[test]
fn filter_mode_with_print_query() {
// --print-query prepends the query line to the output.
let (code, stdout, _) = run_sk("apple\\nbanana", "-f a --print-query");
assert_eq!(code, Some(0));
let mut lines = stdout.lines();
assert_eq!(lines.next(), Some("a"));
}
#[test]
fn filter_mode_with_print0() {
// --print0 separates output records with NUL instead of newline.
let (code, stdout, _) = run_sk("apple\\nbanana", "-f a --print0");
assert_eq!(code, Some(0));
assert!(stdout.contains('\0'));
}
#[test]
fn filter_mode_no_sort_preserves_input_order() {
// Workers grab 4096-item chunks from a shared queue, so with enough items
// each worker processes several chunks and the concatenation order of
// worker results is nondeterministic. --no-sort must restore input order.
let input: String = (0..50_000).map(|i| format!("item{i:06} x\n")).collect();
let expected: Vec<String> = (0..50_000).map(|i| format!("item{i:06} x")).collect();
let (code, stdout, _) = run_sk_argv(&input, &["--no-sort", "-f", "x"], &[]);
assert_eq!(code, Some(0));
let lines: Vec<String> = stdout.lines().map(String::from).collect();
assert_eq!(lines.len(), expected.len());
let first_mismatch = lines.iter().zip(&expected).position(|(a, b)| a != b);
assert!(
first_mismatch.is_none(),
"output diverges from input order at line {first_mismatch:?}"
);
}
#[test]
fn with_nth_accepts_space_separated_negative_index() {
// `--with-nth -1` (space form) used to be parsed as a missing value, while
// `--nth -1` and `--with-nth=-1` both worked.
let (code, stdout, stderr) = run_sk_argv("a b c", &["-f", "c", "--with-nth", "-1"], &[]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout.trim_end(), "a b c");
// The space form and the `=` form must agree.
let (code_eq, stdout_eq, _) = run_sk_argv("a b c", &["-f", "c", "--with-nth=-1"], &[]);
assert_eq!((code, stdout), (code_eq, stdout_eq));
}
#[test]
fn nth_index_past_i32_does_not_fall_back_to_field_1() {
// An index too large for i32 used to fail to parse and silently become field 1,
// so `--nth <huge>` matched the first field instead of matching nothing.
let (code_huge, out_huge, _) = run_sk_argv("a b c", &["-f", "a", "--nth", "2147483648"], &[]);
let (code_oob, out_oob, _) = run_sk_argv("a b c", &["-f", "a", "--nth", "5"], &[]);
assert_eq!((code_huge, out_huge.as_str()), (code_oob, out_oob.as_str()));
assert_eq!(code_huge, Some(1), "an out-of-range field must match nothing");
assert!(out_huge.is_empty());
// Same for the `{N}` field syntax in --output-format. Assert the exit status and
// stderr too, so an empty stdout can't pass by way of the placeholder erroring out.
for placeholder in ["{2147483648}", "{-2147483649}"] {
let (code, out, err) = run_sk_argv("a b c", &["-1", "-q", "a", "--output-format", placeholder], &[]);
assert_eq!(code, Some(0), "{placeholder}: stderr: {err}");
assert_eq!(err, "", "{placeholder} should not error");
assert_eq!(out.trim_end(), "", "{placeholder} should render an empty field");
}
}
#[test]
fn select_1_with_output_format() {
// --output-format renders the selected item through the printf branch.
let (code, stdout, _) = run_sk("1\\n2\\n3", "--select-1 -q 3 --output-format '{}'");
assert_eq!(code, Some(0));
assert!(stdout.contains('3'));
}
#[test]
fn filter_mode_output_format_current_item_is_empty() {
// `{}` expands to the *current* (highlighted) item, just like in previews.
// Filter mode has no interactive cursor, so there is no current item and the
// token expands to nothing — only the trailing record separator is emitted.
let (code, stdout, _) = run_sk("apple\\nbanana", "-f a --output-format '{}'");
assert_eq!(code, Some(0));
assert!(
stdout.trim().is_empty(),
"`{{}}` has no current item in filter mode (got {stdout:?})"
);
}
#[test]
fn filter_mode_output_format_all_items_token() {
// `{+}` expands to every matched item, so it works in filter mode where
// there is no single current item.
let (code, stdout, _) = run_sk("apple\\nbanana\\ncherry", "-f a --output-format '{+}'");
assert_eq!(code, Some(0));
assert!(stdout.contains("apple"), "got {stdout:?}");
assert!(stdout.contains("banana"), "got {stdout:?}");
assert!(
!stdout.contains("cherry"),
"non-matching item must be excluded (got {stdout:?})"
);
}
#[test]
fn select_1_writes_history_file() {
use std::io::Read;
// A history file records the query on exit (covers write_history_to_file in
// the real binary).
let hist = std::env::temp_dir().join(format!("sk_hist_{}", std::process::id()));
let hist_path = hist.to_str().unwrap();
// Pass argv explicitly: the temp path may contain spaces on some platforms.
let (code, _stdout, _) = run_sk_argv("1\\n2\\n3", &["--select-1", "-q", "3", "--history", hist_path], &[]);
assert_eq!(code, Some(0));
let mut contents = String::new();
std::fs::File::open(&hist)
.expect("history file should exist")
.read_to_string(&mut contents)
.unwrap();
assert!(contents.contains('3'));
let _ = std::fs::remove_file(&hist);
}
#[test]
fn select_1_print_current() {
// --print-current prints the current item line before the selected items.
let (code, stdout, _) = run_sk("1\\n2\\n3", "--select-1 -q 3 --print-current");
assert_eq!(code, Some(0));
assert!(stdout.contains('3'));
}
#[test]
fn log_file_initializes_logger() {
// --log-file routes env_logger to a file (covers init_logger's Pipe target
// and builder). SKIM_LOG=trace makes the run actually emit records.
let log = std::env::temp_dir().join(format!("sk_log_{}", std::process::id()));
let log_path = log.to_str().unwrap();
let (code, _stdout, _) = run_sk_argv(
"1\\n2\\n3",
&["--select-1", "-q", "3", "--log-file", log_path],
&[("SKIM_LOG", "trace")],
);
assert_eq!(code, Some(0));
// The log file was created by the file target.
assert!(log.exists());
let _ = std::fs::remove_file(&log);
}
#[test]
fn shell_completion_bash() {
// --shell bash generates a completion script and exits 0 without reading stdin.
let (code, stdout, _) = run_sk("", "--shell bash");
assert_eq!(code, Some(0));
assert!(!stdout.is_empty());
}
#[test]
fn shell_completion_with_key_bindings() {
// --shell zsh together with --shell-bindings emits bindings too.
let (code, stdout, _) = run_sk("", "--shell zsh --shell-bindings");
assert_eq!(code, Some(0));
assert!(!stdout.is_empty());
}
#[test]
fn man_page_generation() {
// --man writes the man page to stdout and exits 0.
let (code, stdout, _) = run_sk("", "--man");
assert_eq!(code, Some(0));
assert!(stdout.contains(".TH") || stdout.to_lowercase().contains("skim"));
}
#[test]
fn select_1_prints_all_metadata_flags() {
// A single matching item with select-1 exits without the TUI and prints all
// the requested metadata lines (query, cmd, header, score).
let (code, stdout, _) = run_sk(
"1\\n2\\n3",
"--select-1 -q 3 --print-query --print-cmd --print-header --print-score",
);
assert_eq!(code, Some(0));
assert!(stdout.contains('3'));
}
#[test]
fn version_flag_exits_zero() {
let (code, stdout, _) = run_sk("", "--version");
assert_eq!(code, Some(0));
assert!(stdout.to_lowercase().contains("sk") || !stdout.is_empty());
}
#[test]
fn help_flag_exits_zero() {
let (code, stdout, _) = run_sk("", "--help");
assert_eq!(code, Some(0));
assert!(!stdout.is_empty());
}
#[test]
fn invalid_flag_exits_with_error() {
// An unknown flag makes clap print usage and exit non-zero (main()'s
// `from_env` error path).
let (code, _stdout, stderr) = run_sk("", "--definitely-not-a-real-flag");
assert_ne!(code, Some(0));
assert!(!stderr.is_empty());
}