navi/tests/shell/lib.bash
Gaurav11112003 e96ef1fbde Add tmux-driven shell plugin tests behind path-filtered CI.
Introduce tests/shell with bash/zsh/fish coverage and deterministic
fixtures; wire a shell-tests workflow gated on shell/ and tests/shell/.
Closes GH-1014. Covers GH-1010-style multiline snippet behavior.
2026-05-18 01:54:34 -05:00

179 lines
5.5 KiB
Bash

#!/usr/bin/env bash
# vim: filetype=sh
#
# tmux-driven helpers used by tests/shell/run to exercise the
# shell plugins (bash / zsh / fish) end-to-end.
#
# Design:
# - Each test spawns a fresh, named tmux session running the target
# shell with the matching `shell/navi.plugin.*` sourced.
# - The plugin invokes navi against a tiny deterministic cheat file
# in tests/shell/cheats, so `--best-match` returns the same snippet
# every time (no live fzf interaction required, except for bash
# which always opens fzf — we accept the only match with Enter).
# - Snippets, when executed, print known `NAVI_TEST::...::END`
# sentinels. Tests assert on captured pane contents instead of
# poking shell-specific editor buffers.
readonly SHELL_TESTS_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SHELL_TESTS_CHEATS_PATH="${SHELL_TESTS_HOME}/cheats"
readonly SHELL_TESTS_RC_DIR="${SHELL_TESTS_HOME}/rc"
# Default polling timeout (seconds) for shell::wait_for. tmux + navi
# need a beat to start, especially on cold CI runners.
readonly SHELL_TESTS_WAIT_TIMEOUT="${SHELL_TESTS_WAIT_TIMEOUT:-15}"
# A prompt marker injected by tests/shell/rc/* so shell::wait_for_prompt
# is deterministic regardless of the runner's $PS1 / $PROMPT defaults.
readonly SHELL_TESTS_PROMPT_MARKER='NAVIPROMPT> '
shell::_session_name() {
local -r shell="$1"
local -r case_id="$2"
echo "navi_shell_test_${shell}_${case_id}_$$"
}
shell::_plugin_path() {
local -r shell="$1"
case "$shell" in
bash) echo "${NAVI_HOME}/shell/navi.plugin.bash" ;;
zsh) echo "${NAVI_HOME}/shell/navi.plugin.zsh" ;;
fish) echo "${NAVI_HOME}/shell/navi.plugin.fish" ;;
*) return 1 ;;
esac
}
# Build the env-prefix tmux launches the shell with. Same for all
# three shells; the rc/init files do the prompt setup and source the
# plugin.
shell::_env_prefix() {
local -r shell="$1"
local -r plugin="$(shell::_plugin_path "$shell")"
local -r navi_dir="$(dirname "$NAVI_EXE")"
printf '%s' \
"env " \
"PATH='${navi_dir}:${PATH}' " \
"TERM='${TERM:-xterm-256color}' " \
"NAVI_CONFIG='${NAVI_HOME}/tests/config.yaml' " \
"NAVI_PATH='${SHELL_TESTS_CHEATS_PATH}' " \
"SHELL_TESTS_PLUGIN='${plugin}' "
}
shell::_launch_cmd() {
local -r shell="$1"
local -r prefix="$(shell::_env_prefix "$shell")"
case "$shell" in
bash)
printf '%s%s' "$prefix" \
"bash --noprofile --rcfile '${SHELL_TESTS_RC_DIR}/bashrc' -i"
;;
zsh)
printf '%s%s%s' "$prefix" \
"ZDOTDIR='${SHELL_TESTS_RC_DIR}/zsh-dotdir' " \
"zsh --no-globalrcs -i"
;;
fish)
local -r plugin="$(shell::_plugin_path "$shell")"
local -r init_cmd="function fish_prompt; echo -n \"${SHELL_TESTS_PROMPT_MARKER}\"; end; source \"${plugin}\""
printf "%sfish --no-config -i -C '%s'" "$prefix" "$init_cmd"
;;
esac
}
# Capture the full visible pane contents, including scrollback.
shell::pane() {
local -r session="$1"
tmux capture-pane -t "$session" -p -S - 2>/dev/null || true
}
# Poll the pane until `pattern` (extended regex) appears, or the
# timeout (in seconds) elapses. Returns 0 on match, 1 on timeout.
shell::wait_for() {
local -r session="$1"
local -r pattern="$2"
local -r timeout="${3:-$SHELL_TESTS_WAIT_TIMEOUT}"
local -r deadline=$(( $(date +%s) + timeout ))
while [ "$(date +%s)" -lt "$deadline" ]; do
if shell::pane "$session" | grep -Eq "$pattern"; then
return 0
fi
sleep 0.2
done
return 1
}
shell::wait_for_prompt() {
shell::wait_for "$1" "$SHELL_TESTS_PROMPT_MARKER" "${2:-}"
}
# Start a fresh tmux session for the given shell. Returns the session
# name on stdout; non-zero on launch failure.
shell::start() {
local -r shell="$1"
local -r case_id="$2"
local -r session="$(shell::_session_name "$shell" "$case_id")"
local -r launch_cmd="$(shell::_launch_cmd "$shell")"
tmux kill-session -t "$session" 2>/dev/null || true
tmux new-session -d -s "$session" -x 200 -y 50 "$launch_cmd"
if ! shell::wait_for_prompt "$session"; then
log::error "Shell '$shell' failed to reach a prompt in session '$session'"
log::error "Launch command was: $launch_cmd"
log::error "Pane contents:"
shell::pane "$session" 1>&2
tmux kill-session -t "$session" 2>/dev/null || true
return 1
fi
echo "$session"
}
shell::stop() {
local -r session="$1"
tmux kill-session -t "$session" 2>/dev/null || true
}
# Literal typing (no key interpretation). Safe for snippet text that
# may contain backslashes, quotes, dashes, etc.
shell::type() {
local -r session="$1"
local -r text="$2"
tmux send-keys -t "$session" -l -- "$text"
}
# Send a tmux key name (e.g. Enter, Escape, C-g, C-c). Multiple keys
# may be passed; each is forwarded as its own send-keys argument.
shell::keys() {
local -r session="$1"
shift
tmux send-keys -t "$session" "$@"
}
# Sends the plugin's bound trigger key. All three target shells use
# Ctrl-G; if a follow-up PR adds elvish (which uses Alt-h) it can
# branch here.
shell::trigger() {
local -r session="$1"
tmux send-keys -t "$session" 'C-g'
}
shell::enter() {
local -r session="$1"
tmux send-keys -t "$session" 'Enter'
}
shell::escape() {
local -r session="$1"
tmux send-keys -t "$session" 'Escape'
}
# Convenience: kill any stray tmux sessions left behind by aborted
# runs. Mirrors `_kill_tmux` in tests/run.
shell::kill_all() {
pkill -f "tmux.*navi_shell_test_.*_$$" 2>/dev/null || true
}