test: Add integration tests and demo script.
This commit is contained in:
52
crates/pikl/tests/common/mod.rs
Normal file
52
crates/pikl/tests/common/mod.rs
Normal 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)
|
||||
}
|
||||
69
crates/pikl/tests/headless.rs
Normal file
69
crates/pikl/tests/headless.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
269
crates/pikl/tests/headless_dsl.rs
Normal file
269
crates/pikl/tests/headless_dsl.rs
Normal 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
159
examples/demo.sh
Executable 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 "$@"
|
||||
Reference in New Issue
Block a user