//! 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, 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, 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_inverse_query_checks_every_nth_field() { // `--nth 1,2` gives each item two matching ranges. An inverse query must // reject an item when ANY of them contains the term, not just the first one. let (code, stdout, _) = run_sk_argv("foo bar\nqux bar\n", &["-f", "!foo", "--nth", "1,2"], &[]); assert_eq!(code, Some(0)); assert!( !stdout.contains("foo bar"), "!foo must exclude 'foo bar' (got {stdout:?})" ); assert!(stdout.contains("qux bar"), "!foo must keep 'qux bar' (got {stdout:?})"); // The term sitting in the second field must be caught too. let (code, stdout, _) = run_sk_argv("bar foo\nbar qux\n", &["-f", "!foo", "--nth", "1,2"], &[]); assert_eq!(code, Some(0)); assert!( !stdout.contains("bar foo"), "!foo must exclude 'bar foo' (got {stdout:?})" ); assert!(stdout.contains("bar qux"), "!foo must keep 'bar qux' (got {stdout:?})"); } #[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 = (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 = 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 ` 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 pathname_tiebreak_is_not_broken_by_a_non_ascii_directory() { // `path_name_offset` used to be a byte offset while `Rank::begin` is a char // index, so a multi-byte directory component inflated the PathName score and // pushed the filename match below the directory match. let (code, stdout, stderr) = run_sk_argv("ééééé/a\na/xxxxx\n", &["-f", "a", "--scheme", "path"], &[]); assert_eq!(code, Some(0), "stderr: {stderr}"); assert_eq!( stdout.lines().next(), Some("ééééé/a"), "the filename match must rank first, got: {stdout:?}" ); // The all-ASCII shape of the same input already ranked correctly; both must agree. let (_, ascii_stdout, _) = run_sk_argv("eeeee/a\na/xxxxx\n", &["-f", "a", "--scheme", "path"], &[]); assert_eq!(ascii_stdout.lines().next(), Some("eeeee/a")); } #[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()); }