mirror of
https://github.com/denisidoro/navi.git
synced 2026-07-28 12:36:40 +00:00
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:
parent
1ac218cb1e
commit
e96ef1fbde
6 changed files with 452 additions and 0 deletions
50
.github/workflows/shell-tests.yml
vendored
Normal file
50
.github/workflows/shell-tests.yml
vendored
Normal 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
|
||||
23
tests/shell/cheats/plugin.cheat
Normal file
23
tests/shell/cheats/plugin.cheat
Normal 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
179
tests/shell/lib.bash
Normal 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
14
tests/shell/rc/bashrc
Normal 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
|
||||
9
tests/shell/rc/zsh-dotdir/.zshrc
Normal file
9
tests/shell/rc/zsh-dotdir/.zshrc
Normal 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
177
tests/shell/run
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue