test: Add integration tests and demo script.

This commit is contained in:
2026-03-13 21:59:33 -04:00
parent 73de161a09
commit fdfb4eaab5
4 changed files with 549 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
use std::io::Write;
use std::os::unix::io::FromRawFd;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
/// Spawn pikl with items on stdin and an action script on fd 3.
/// Returns (stdout, stderr, exit_code).
pub fn run_pikl(items: &str, script: &str, extra_args: &[&str]) -> (String, String, i32) {
let mut fds = [0i32; 2];
unsafe { libc::pipe(fds.as_mut_ptr()) };
let [read_fd, write_fd] = fds;
let mut write_file = unsafe { std::fs::File::from_raw_fd(write_fd) };
write_file.write_all(script.as_bytes()).unwrap_or_default();
drop(write_file);
let target_fd = 3i32;
let mut cmd = Command::new(env!("CARGO_BIN_EXE_pikl"));
cmd.args(["--action-fd", "3"])
.args(extra_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
unsafe {
cmd.pre_exec(move || {
if read_fd != target_fd {
libc::dup2(read_fd, target_fd);
libc::close(read_fd);
}
Ok(())
});
}
let Ok(mut child) = cmd.spawn() else {
return ("".to_string(), "failed to spawn pikl".to_string(), -1);
};
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(items.as_bytes()).unwrap_or_default();
}
let Ok(output) = child.wait_with_output() else {
return ("".to_string(), "failed to wait for pikl".to_string(), -1);
};
let stdout = String::from_utf8(output.stdout).unwrap_or_default();
let stderr = String::from_utf8(output.stderr).unwrap_or_default();
let code = output.status.code().unwrap_or(-1);
(stdout, stderr, code)
}

View File

@@ -0,0 +1,69 @@
#![cfg(unix)]
mod common;
#[test]
fn headless_confirm_first() {
let (stdout, _stderr, code) = common::run_pikl("alpha\nbeta\n", "confirm\n", &[]);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains("alpha"),
"expected alpha in stdout, got: {stdout}"
);
}
#[test]
fn headless_move_then_confirm() {
let (stdout, _stderr, code) = common::run_pikl("alpha\nbeta\n", "move-down\nconfirm\n", &[]);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains("beta"),
"expected beta in stdout, got: {stdout}"
);
}
#[test]
fn headless_filter_then_confirm() {
let (stdout, _stderr, code) =
common::run_pikl("alpha\nbeta\nbanana\n", "filter ban\nconfirm\n", &[]);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains("banana"),
"expected banana in stdout, got: {stdout}"
);
}
#[test]
fn headless_cancel() {
let (_stdout, _stderr, code) = common::run_pikl("alpha\n", "cancel\n", &[]);
assert_eq!(code, 1, "expected exit 1 on cancel");
}
#[test]
fn headless_empty_script_cancels() {
let (_stdout, _stderr, code) = common::run_pikl("alpha\n", "", &[]);
assert_eq!(
code, 1,
"expected exit 1 when script is empty (sender dropped → cancelled)"
);
}
#[test]
fn headless_invalid_script_exits_2() {
let (_stdout, stderr, code) = common::run_pikl("alpha\n", "bogus\n", &[]);
assert_eq!(code, 2, "expected exit 2 on invalid script");
assert!(
stderr.contains("unknown action"),
"expected error diagnostic, got: {stderr}"
);
}
#[test]
fn headless_actions_after_show_ui_exits_2() {
let (_stdout, stderr, code) = common::run_pikl("alpha\n", "show-ui\nconfirm\n", &[]);
assert_eq!(code, 2, "expected exit 2 for actions after show-ui");
assert!(
stderr.contains("actions after show-ui"),
"expected show-ui error, got: {stderr}"
);
}

View File

@@ -0,0 +1,269 @@
#![cfg(unix)]
mod common;
use common::run_pikl;
use pikl_test_macros::pikl_tests;
pikl_tests! {
headless mod basic_selection {
items: ["alpha", "beta", "charlie"];
test confirm_first {
actions: [confirm]
stdout: "alpha"
exit: 0
}
test move_down_and_confirm {
actions: [move-down, confirm]
stdout: "beta"
exit: 0
}
test move_to_third {
actions: [move-down, move-down, confirm]
stdout: "charlie"
exit: 0
}
test cancel_exits_1 {
actions: [cancel]
stdout: ""
exit: 1
}
test empty_script_cancels {
actions: []
exit: 1
}
}
headless mod filtering {
items: ["alpha", "beta", "banana"];
test filter_then_confirm {
actions: [filter "ban", confirm]
stdout: "banana"
exit: 0
}
}
headless mod errors {
items: ["one", "two"];
test invalid_action {
actions: [raw "bogus"]
stderr contains: "unknown action"
exit: 2
}
test actions_after_show_ui {
actions: [raw "show-ui", raw "confirm"]
stderr contains: "actions after show-ui"
exit: 2
}
}
// ── Demo scenario tests ──────────────────────────────
// These cover the scenarios from examples/demo.sh so
// we catch regressions in the demo workflows.
headless mod demo_plain_text {
items: [
"apple", "banana", "cherry", "date",
"elderberry", "fig", "grape", "honeydew"
];
test confirm_first {
actions: [confirm]
stdout: "apple"
exit: 0
}
test navigate_to_last {
actions: [move-to-bottom, confirm]
stdout: "honeydew"
exit: 0
}
test filter_then_confirm {
actions: [filter "cher", confirm]
stdout: "cherry"
exit: 0
}
test filter_narrows_to_one {
actions: [filter "elder", confirm]
stdout: "elderberry"
exit: 0
}
test cancel {
actions: [cancel]
exit: 1
}
}
headless mod demo_big_list {
// Bare numbers parse as JSON numbers with empty labels,
// so we prefix with "item-" to keep them as plain text.
items: [
"item-1", "item-2", "item-3", "item-4", "item-5",
"item-6", "item-7", "item-8", "item-9", "item-10",
"item-11", "item-12", "item-13", "item-14", "item-15",
"item-16", "item-17", "item-18", "item-19", "item-20",
"item-50", "item-100", "item-200", "item-499", "item-500"
];
test confirm_first {
actions: [confirm]
stdout: "item-1"
exit: 0
}
test move_to_bottom {
actions: [move-to-bottom, confirm]
stdout: "item-500"
exit: 0
}
test filter_exact {
actions: [filter "item-499", confirm]
stdout: "item-499"
exit: 0
}
test page_down_then_confirm {
actions: [page-down, confirm]
exit: 0
}
}
headless mod demo_json_objects {
items: [
"{\"label\": \"Arch Linux\", \"category\": \"rolling\", \"init\": \"systemd\"}",
"{\"label\": \"NixOS\", \"category\": \"rolling\", \"init\": \"systemd\"}",
"{\"label\": \"Void Linux\", \"category\": \"rolling\", \"init\": \"runit\"}",
"{\"label\": \"Debian\", \"category\": \"stable\", \"init\": \"systemd\"}",
"{\"label\": \"Alpine\", \"category\": \"stable\", \"init\": \"openrc\"}",
"{\"label\": \"Fedora\", \"category\": \"semi-rolling\", \"init\": \"systemd\"}",
"{\"label\": \"Gentoo\", \"category\": \"rolling\", \"init\": \"openrc\"}"
];
test confirm_first {
actions: [confirm]
stdout: "Arch Linux"
exit: 0
}
test preserves_json_fields {
actions: [confirm]
stdout: "rolling"
exit: 0
}
test filter_by_label {
actions: [filter "Void", confirm]
stdout: "Void Linux"
exit: 0
}
test navigate_to_debian {
actions: [move-down, move-down, move-down, confirm]
stdout: "Debian"
exit: 0
}
test filter_and_navigate {
actions: [filter "Linux", move-down, confirm]
stdout: "Void Linux"
exit: 0
}
test cancel {
actions: [cancel]
exit: 1
}
}
headless mod demo_custom_label_key {
items: [
"{\"name\": \"Neovim\", \"type\": \"editor\", \"lang\": \"C/Lua\"}",
"{\"name\": \"Helix\", \"type\": \"editor\", \"lang\": \"Rust\"}",
"{\"name\": \"Kakoune\", \"type\": \"editor\", \"lang\": \"C++\"}",
"{\"name\": \"Emacs\", \"type\": \"editor\", \"lang\": \"Lisp\"}",
"{\"name\": \"Vim\", \"type\": \"editor\", \"lang\": \"C\"}"
];
label_key: "name";
test confirm_first {
actions: [confirm]
stdout: "Neovim"
exit: 0
}
test filter_by_name {
actions: [filter "Hel", confirm]
stdout: "Helix"
exit: 0
}
test preserves_lang_field {
actions: [move-down, confirm]
stdout: "Rust"
exit: 0
}
test navigate_to_last {
actions: [move-to-bottom, confirm]
stdout: "Vim"
exit: 0
}
}
headless mod demo_mixed_input {
items: [
"just a plain string",
"{\"label\": \"a json object\", \"extra\": 42}",
"another plain string",
"{\"label\": \"second object\", \"extra\": 99}"
];
test confirm_plain_text {
actions: [confirm]
stdout: "just a plain string"
exit: 0
}
test confirm_json_object {
actions: [move-down, confirm]
stdout: "a json object"
exit: 0
}
test json_preserves_extra {
actions: [move-down, confirm]
stdout: "42"
exit: 0
}
test navigate_to_second_plain {
actions: [move-down, move-down, confirm]
stdout: "another plain string"
exit: 0
}
test navigate_to_second_json {
actions: [move-down, move-down, move-down, confirm]
stdout: "second object"
exit: 0
}
test filter_across_types {
actions: [filter "plain", confirm]
stdout: "just a plain string"
exit: 0
}
}
}