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.
This commit is contained in:
Gaurav11112003 2026-05-18 01:54:34 -05:00
parent 1ac218cb1e
commit e96ef1fbde
6 changed files with 452 additions and 0 deletions

50
.github/workflows/shell-tests.yml vendored Normal file
View file

@ -0,0 +1,50 @@
# Dedicated CI for the shell plugins under `shell/`.
#
# Lives in its own workflow with path filters so the extra zsh/fish/tmux
# install never slows down PRs that don't touch shell plugin code.
#
# Tracks issue: https://github.com/denisidoro/navi/issues/1014
on:
push:
paths:
- 'shell/**'
- 'tests/shell/**'
- '.github/workflows/shell-tests.yml'
pull_request:
branches: [master]
paths:
- 'shell/**'
- 'tests/shell/**'
- '.github/workflows/shell-tests.yml'
name: Shell plugin tests
jobs:
shell-tests:
name: Shell plugin tests
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- name: Build navi (debug)
uses: actions-rs/cargo@v1
with:
command: build
- name: Install shell plugin deps (zsh, fish, tmux)
run: sudo apt-get update && sudo apt-get install -y zsh fish tmux
- name: Install fzf
run: git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf; yes | ~/.fzf/install;
- name: Run shell plugin tests
run: ./tests/shell/run

View file

@ -0,0 +1,23 @@
; author: navi shell-plugin tests
;
; Deterministic fixture for tests/shell/run.
;
; - `ping` -> single-line snippet, used by the
; `<shell>::query_driven` cases for bash/zsh/fish.
; - `multiline_snippet` -> two distinct commands on two physical
; lines, with no `\` continuation. If a
; shell plugin flattens newlines (the bug
; fixed in #1010), the second `echo`
; collapses into args of the first, so the
; second sentinel never appears on a line
; of its own. The fish regression case
; asserts that exact post-condition.
% navi-shell-tests, plugin
# ping
echo "NAVI_TEST::ping_ok::END"
# multiline_snippet
echo "NAVI_TEST::multi_line_1::END"
echo "NAVI_TEST::multi_line_2::END"

179
tests/shell/lib.bash Normal file
View file

@ -0,0 +1,179 @@
#!/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
}

14
tests/shell/rc/bashrc Normal file
View file

@ -0,0 +1,14 @@
# Minimal interactive bash startup used by tests/shell/run.
# Loaded via `bash --rcfile <this-file> -i`.
PS1='NAVIPROMPT> '
PS2='> '
# Disable history expansion so test queries containing `!` don't blow
# up, and disable command-not-found hooks for cleaner pane output.
set +H
unset PROMPT_COMMAND
if [ -n "${SHELL_TESTS_PLUGIN:-}" ] && [ -f "$SHELL_TESTS_PLUGIN" ]; then
source "$SHELL_TESTS_PLUGIN"
fi

View file

@ -0,0 +1,9 @@
# Minimal interactive zsh startup used by tests/shell/run.
# Loaded via `ZDOTDIR=<this-dir> zsh --no-globalrcs -i`.
PROMPT='NAVIPROMPT> '
PS2='> '
if [ -n "${SHELL_TESTS_PLUGIN:-}" ] && [ -f "$SHELL_TESTS_PLUGIN" ]; then
source "$SHELL_TESTS_PLUGIN"
fi

177
tests/shell/run Executable file
View file

@ -0,0 +1,177 @@
#!/usr/bin/env bash
# vim: filetype=sh
#
# Entry point for the shell-plugin test suite (issue #1014).
#
# Spawns each target shell inside tmux, sources the matching
# `shell/navi.plugin.*` file, and exercises Ctrl-G against a
# deterministic cheat fixture in tests/shell/cheats/.
#
# Reuses the existing `test::` framework from tests/core.bash so
# results render the same way as the main test runner.
set -euo pipefail
export NAVI_HOME="$(cd "$(dirname "$0")/../.." && pwd)"
source "${NAVI_HOME}/tests/core.bash"
source "${NAVI_HOME}/tests/shell/lib.bash"
export NAVI_EXE="${NAVI_EXE:-${NAVI_HOME}/target/debug/navi}"
# Make navi resolvable from PATH inside the spawned tmux sessions.
if ! command_exists fzf; then
export PATH="$PATH:$HOME/.fzf/bin"
fi
if [ ! -x "$NAVI_EXE" ]; then
log::error "navi debug binary not found at $NAVI_EXE"
log::note "Run 'cargo build' first."
exit 1
fi
for cmd in tmux fzf; do
if ! command_exists "$cmd"; then
log::error "required dependency '$cmd' is not installed"
exit 1
fi
done
trap 'shell::kill_all' EXIT INT TERM
# ----- per-case implementations ---------------------------------------
# Shell loads the plugin without error and reaches a fresh prompt.
case::loads() {
local -r shell="$1"
local session
session="$(shell::start "$shell" "loads")" || return 1
shell::stop "$session"
}
# Type a query, trigger the widget, accept fzf if it shows up, then
# execute the spliced snippet and assert the sentinel reached stdout.
case::query_driven() {
local -r shell="$1"
local session
session="$(shell::start "$shell" "query")" || return 1
shell::type "$session" "ping"
shell::trigger "$session"
local -r snippet_marker='echo "NAVI_TEST::ping_ok::END"'
local -r fzf_marker='[0-9]+/[0-9]+'
if ! shell::wait_for "$session" "(${snippet_marker}|${fzf_marker})"; then
log::error "[$shell] neither snippet nor fzf appeared after C-g"
shell::pane "$session" 1>&2
shell::stop "$session"
return 1
fi
if ! shell::pane "$session" | grep -Fq "$snippet_marker"; then
shell::enter "$session"
if ! shell::wait_for "$session" "$snippet_marker"; then
log::error "[$shell] snippet never reached the buffer after fzf accept"
shell::pane "$session" 1>&2
shell::stop "$session"
return 1
fi
fi
shell::enter "$session"
local rc=0
if ! shell::wait_for "$session" '^NAVI_TEST::ping_ok::END[[:space:]]*$'; then
log::error "[$shell] sentinel never appeared as its own output line"
shell::pane "$session" 1>&2
rc=1
fi
shell::stop "$session"
return "$rc"
}
# Regression test for https://github.com/denisidoro/navi/pull/1010.
#
# The fixture's `multiline_snippet` has two `echo`s on two physical
# lines (no `\` continuation). If the plugin preserves newlines, each
# echo runs as its own command and both sentinels appear anchored to
# their own output lines. If the plugin flattens (the old fish bug),
# the second echo collapses into arguments of the first and the second
# sentinel never appears alone on a line.
case::multiline_preserved() {
local -r shell="$1"
local session
session="$(shell::start "$shell" "multiline")" || return 1
shell::type "$session" "multiline_snippet"
shell::trigger "$session"
local -r snippet_marker='NAVI_TEST::multi_line_1::END'
local -r fzf_marker='[0-9]+/[0-9]+'
if ! shell::wait_for "$session" "(${snippet_marker}|${fzf_marker})"; then
log::error "[$shell] multi-line snippet did not appear after C-g"
shell::pane "$session" 1>&2
shell::stop "$session"
return 1
fi
if ! shell::pane "$session" | grep -Fq 'echo "NAVI_TEST::multi_line_1::END"'; then
shell::enter "$session"
if ! shell::wait_for "$session" 'echo "NAVI_TEST::multi_line_1::END"'; then
log::error "[$shell] multi-line snippet never reached the buffer"
shell::pane "$session" 1>&2
shell::stop "$session"
return 1
fi
fi
shell::enter "$session"
local rc=0
if ! shell::wait_for "$session" '^NAVI_TEST::multi_line_1::END[[:space:]]*$'; then
log::error "[$shell] first sentinel not on its own output line"
shell::pane "$session" 1>&2
rc=1
fi
if [ "$rc" -eq 0 ] && ! shell::wait_for "$session" '^NAVI_TEST::multi_line_2::END[[:space:]]*$' 5; then
log::error "[$shell] second sentinel not on its own output line (newlines were likely flattened)"
shell::pane "$session" 1>&2
rc=1
fi
shell::stop "$session"
return "$rc"
}
# ----- runner ---------------------------------------------------------
cd "$NAVI_HOME"
shell::kill_all
for shell in bash zsh fish; do
if ! command_exists "$shell"; then
test::set_suite "$shell"
test::skip "shell '$shell' is not installed"
continue
fi
test::set_suite "$shell"
test::run "loads" case::loads "$shell"
test::run "query_driven" case::query_driven "$shell"
done
# Multi-line snippet preservation is the regression that motivated
# this entire suite (#1010). It's fish-specific in origin but cheap
# to assert against bash/zsh too, so any future shell-side flattening
# in either of those would also be caught.
for shell in bash zsh fish; do
if ! command_exists "$shell"; then
continue
fi
test::set_suite "${shell} regressions"
test::run "multiline_snippet (#1010)" case::multiline_preserved "$shell"
done
test::finish