From fdfb4eaab5f9a74ea04e0544f3976226c8dc7e54 Mon Sep 17 00:00:00 2001 From: "J. Champagne" Date: Fri, 13 Mar 2026 21:59:33 -0400 Subject: [PATCH] test: Add integration tests and demo script. --- crates/pikl/tests/common/mod.rs | 52 ++++++ crates/pikl/tests/headless.rs | 69 ++++++++ crates/pikl/tests/headless_dsl.rs | 269 ++++++++++++++++++++++++++++++ examples/demo.sh | 159 ++++++++++++++++++ 4 files changed, 549 insertions(+) create mode 100644 crates/pikl/tests/common/mod.rs create mode 100644 crates/pikl/tests/headless.rs create mode 100644 crates/pikl/tests/headless_dsl.rs create mode 100755 examples/demo.sh diff --git a/crates/pikl/tests/common/mod.rs b/crates/pikl/tests/common/mod.rs new file mode 100644 index 0000000..e1b11d2 --- /dev/null +++ b/crates/pikl/tests/common/mod.rs @@ -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) +} diff --git a/crates/pikl/tests/headless.rs b/crates/pikl/tests/headless.rs new file mode 100644 index 0000000..3f496ad --- /dev/null +++ b/crates/pikl/tests/headless.rs @@ -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}" + ); +} diff --git a/crates/pikl/tests/headless_dsl.rs b/crates/pikl/tests/headless_dsl.rs new file mode 100644 index 0000000..6d17b02 --- /dev/null +++ b/crates/pikl/tests/headless_dsl.rs @@ -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 + } + } +} diff --git a/examples/demo.sh b/examples/demo.sh new file mode 100755 index 0000000..76c1ac9 --- /dev/null +++ b/examples/demo.sh @@ -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 "$@"