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
}
}
}

159
examples/demo.sh Executable file
View File

@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# Interactive demo launcher for pikl-menu.
# Uses pikl to pick a scenario, then runs that scenario in pikl.
#
# Usage: ./examples/demo.sh
set -euo pipefail
# Resolve the pikl binary once up front.
if [[ -n "${PIKL:-}" ]]; then
PIKL_BIN="$PIKL"
elif command -v pikl >/dev/null 2>&1; then
PIKL_BIN="pikl"
else
# Build quietly, use the debug binary directly.
cargo build --quiet 2>&1
PIKL_BIN="cargo run --quiet --"
fi
# Wrapper so scenarios can just call `pikl` with args.
pikl() {
$PIKL_BIN "$@"
}
# ── Scenario runners ──────────────────────────────────────
plain_list() {
printf "apple\nbanana\ncherry\ndate\nelderberry\nfig\ngrape\nhoneydew\n" \
| pikl
}
big_list() {
# seq output is wrapped as JSON strings so they get
# proper labels (bare numbers parse as JSON numbers
# with empty display text).
seq 1 500 | sed 's/.*/"&"/' | pikl
}
json_objects() {
cat <<'ITEMS' | pikl
{"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"}
ITEMS
}
custom_label_key() {
cat <<'ITEMS' | pikl --label-key name
{"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"}
ITEMS
}
git_branches() {
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "not in a git repo" >&2
return 1
fi
git branch --format='%(refname:short)' | pikl
}
git_log_picker() {
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "not in a git repo" >&2
return 1
fi
git log --oneline -30 | pikl
}
file_picker() {
find . -maxdepth 3 -type f \
-not -path './.git/*' \
-not -path './target/*' \
-not -name '*.lock' \
| sort \
| pikl
}
on_select_hook() {
printf "one\ntwo\nthree\nfour\nfive\n" \
| pikl --on-select 'echo "you picked: $(cat)"'
}
mixed_input() {
cat <<'ITEMS' | pikl
just a plain string
{"label": "a json object", "extra": 42}
another plain string
{"label": "second object", "extra": 99}
ITEMS
}
# ── Scenario menu ─────────────────────────────────────────
scenarios=(
"Plain text list"
"Big list (500 items)"
"JSON objects (distros)"
"Custom --label-key (editors)"
"Git branches"
"Git log (last 30)"
"File picker"
"on-select hook"
"Mixed input (plain + JSON)"
)
# Map display names to functions
run_scenario() {
case "$1" in
*"Plain text"*) plain_list ;;
*"Big list"*) big_list ;;
*"JSON objects"*) json_objects ;;
*"label-key"*) custom_label_key ;;
*"Git branches"*) git_branches ;;
*"Git log"*) git_log_picker ;;
*"File picker"*) file_picker ;;
*"on-select"*) on_select_hook ;;
*"Mixed input"*) mixed_input ;;
*)
echo "unknown scenario" >&2
return 1
;;
esac
}
# ── Main ──────────────────────────────────────────────────
main() {
echo "pikl demo launcher" >&2
echo "pick a scenario, then interact with it" >&2
echo "" >&2
choice=$(printf '%s\n' "${scenarios[@]}" | pikl) || {
echo "cancelled" >&2
exit 1
}
# pikl outputs JSON. Strip the quotes for matching.
choice=$(echo "$choice" | tr -d '"')
echo "" >&2
echo "── running: $choice ──" >&2
echo "" >&2
result=$(run_scenario "$choice") || exit $?
echo "" >&2
echo "── result ──" >&2
echo "$result"
}
main "$@"