From e96ef1fbde7a26e19fed0c697e9a51ba04fb2c7e Mon Sep 17 00:00:00 2001 From: Gaurav11112003 Date: Mon, 18 May 2026 01:54:34 -0500 Subject: [PATCH] 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. --- .github/workflows/shell-tests.yml | 50 +++++++++ tests/shell/cheats/plugin.cheat | 23 ++++ tests/shell/lib.bash | 179 ++++++++++++++++++++++++++++++ tests/shell/rc/bashrc | 14 +++ tests/shell/rc/zsh-dotdir/.zshrc | 9 ++ tests/shell/run | 177 +++++++++++++++++++++++++++++ 6 files changed, 452 insertions(+) create mode 100644 .github/workflows/shell-tests.yml create mode 100644 tests/shell/cheats/plugin.cheat create mode 100644 tests/shell/lib.bash create mode 100644 tests/shell/rc/bashrc create mode 100644 tests/shell/rc/zsh-dotdir/.zshrc create mode 100755 tests/shell/run diff --git a/.github/workflows/shell-tests.yml b/.github/workflows/shell-tests.yml new file mode 100644 index 0000000..b66cb21 --- /dev/null +++ b/.github/workflows/shell-tests.yml @@ -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 diff --git a/tests/shell/cheats/plugin.cheat b/tests/shell/cheats/plugin.cheat new file mode 100644 index 0000000..a3ac23e --- /dev/null +++ b/tests/shell/cheats/plugin.cheat @@ -0,0 +1,23 @@ +; author: navi shell-plugin tests +; +; Deterministic fixture for tests/shell/run. +; +; - `ping` -> single-line snippet, used by the +; `::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" diff --git a/tests/shell/lib.bash b/tests/shell/lib.bash new file mode 100644 index 0000000..7a5335d --- /dev/null +++ b/tests/shell/lib.bash @@ -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 +} diff --git a/tests/shell/rc/bashrc b/tests/shell/rc/bashrc new file mode 100644 index 0000000..856044b --- /dev/null +++ b/tests/shell/rc/bashrc @@ -0,0 +1,14 @@ +# Minimal interactive bash startup used by tests/shell/run. +# Loaded via `bash --rcfile -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 diff --git a/tests/shell/rc/zsh-dotdir/.zshrc b/tests/shell/rc/zsh-dotdir/.zshrc new file mode 100644 index 0000000..0dfd741 --- /dev/null +++ b/tests/shell/rc/zsh-dotdir/.zshrc @@ -0,0 +1,9 @@ +# Minimal interactive zsh startup used by tests/shell/run. +# Loaded via `ZDOTDIR= zsh --no-globalrcs -i`. + +PROMPT='NAVIPROMPT> ' +PS2='> ' + +if [ -n "${SHELL_TESTS_PLUGIN:-}" ] && [ -f "$SHELL_TESTS_PLUGIN" ]; then + source "$SHELL_TESTS_PLUGIN" +fi diff --git a/tests/shell/run b/tests/shell/run new file mode 100755 index 0000000..776dd58 --- /dev/null +++ b/tests/shell/run @@ -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