diff --git a/ralph_loop.sh b/ralph_loop.sh index a7c2f1e0..345436d8 100755 --- a/ralph_loop.sh +++ b/ralph_loop.sh @@ -482,25 +482,33 @@ setup_tmux_session() { local ralph_home="${RALPH_HOME:-$HOME/.ralph}" local project_dir="$(pwd)" - # Get the tmux base-index / pane-base-index to handle custom configurations - # (e.g. `set -g base-index 1`, `setw -g pane-base-index 1` — very common in - # dotfiles). Without this, pane targets like `.0` don't exist and the Ralph - # loop never starts, leaving empty panes. See: tmux pane-base-index. - local base_win base_pane - base_win=$(get_tmux_base_index) - base_pane=$(get_tmux_pane_base_index) - local pane0=$((base_pane + 0)) # Ralph loop (left) - local pane1=$((base_pane + 1)) # Claude output (right-top) - local pane2=$((base_pane + 2)) # Status monitor (right-bottom) + # base-index / pane-base-index are detected AFTER the server starts (below). + # Querying earlier fails on the very first `ralph --monitor` run when no tmux + # server exists yet: `tmux show-options` does NOT auto-start a server, so it + # errors out and detection silently defaults to 0. With a `base-index 1` / + # `pane-base-index 1` config that makes every `window.pane` target off-by-one + # (e.g. `1.0`, window `0`), the loop command is sent to a nonexistent pane, + # and tmux opens to empty idle panes. Starting the server first makes the + # detection reliable. See: tmux pane-base-index. + local base_win base_pane pane0 pane1 pane2 log_status "INFO" "Setting up tmux session: $session_name" # Initialize live.log file echo "=== Ralph Live Output - Waiting for first loop... ===" > "$LIVE_LOG_FILE" - # Create new tmux session detached (left pane - Ralph loop) + # Create new tmux session detached (left pane - Ralph loop). This also starts + # the tmux server (sourcing the user's config), so the base-index / + # pane-base-index detection below is reliable even on the very first run. tmux new-session -d -s "$session_name" -c "$project_dir" + # Detect base-index / pane-base-index now that the server is running. + base_win=$(get_tmux_base_index) + base_pane=$(get_tmux_pane_base_index) + pane0=$((base_pane + 0)) # Ralph loop (left) + pane1=$((base_pane + 1)) # Claude output (right-top) + pane2=$((base_pane + 2)) # Status monitor (right-bottom) + # Split window vertically (right side) tmux split-window -h -t "$session_name" -c "$project_dir" diff --git a/tests/integration/test_monitor.bats b/tests/integration/test_monitor.bats index 978b8d7c..c8ca5808 100644 --- a/tests/integration/test_monitor.bats +++ b/tests/integration/test_monitor.bats @@ -1,10 +1,11 @@ #!/usr/bin/env bats # Integration tests for ralph_monitor.sh dashboard (Issue #15) # -# Sourcing strategy: `head -n -1` loads all function definitions from -# ralph_monitor.sh without triggering the unconditional `main` call on -# the last line. This mirrors the inline/source pattern used in -# test_tmux_integration.bats and test_loop_execution.bats. +# Sourcing strategy: strip the final `main` line with `sed '$d'` (portable — +# `head -n -1` is GNU-only and errors on BSD), then source from a TEMP FILE. +# `source <(process-substitution)` does NOT define functions on macOS bash 3.2 +# (they silently stay undefined), so the pipe→file→source form is required for +# portability. Mirrors the inline/source pattern in test_tmux_integration.bats. bats_require_minimum_version 1.5.0 @@ -18,13 +19,18 @@ setup() { MONITOR_SCRIPT="${BATS_TEST_DIRNAME}/../../ralph_monitor.sh" - # Load all monitor functions without calling main(). - # head -n -1 strips the bare `main` call on the final line. + # Load all monitor functions without calling main(). `sed '$d'` strips the + # bare `main` call on the final line (portable — `head -n -1` is GNU-only). # The `trap cleanup ...` line is filtered out: sourcing it would REPLACE # bats' own EXIT trap, silently swallowing failing tests (the file then # reports "Executed N-1 instead of expected N" with no `not ok` line). + # Write to a temp file and source THAT: `source <(process-substitution)` does + # NOT define functions on macOS bash 3.2 (they silently stay undefined), so + # the pipe→file→source form is required for portability. + _monitor_funcs="$TEST_DIR/.monitor_funcs.sh" + sed '$d' "$MONITOR_SCRIPT" | grep -v '^trap cleanup ' > "$_monitor_funcs" # shellcheck disable=SC1090 - source <(head -n -1 "$MONITOR_SCRIPT" | grep -v '^trap cleanup ') + source "$_monitor_funcs" # Override clear_screen to suppress terminal-escape side-effects in tests. clear_screen() { :; } @@ -226,12 +232,12 @@ EOF return 1 } - # Verify ralph_monitor.sh registers an EXIT trap that calls cleanup + # Verify ralph_monitor.sh registers an EXIT trap that calls cleanup. + # Source from a temp file (not `source <(...)` — broken on macOS bash 3.2). + local _trap_src="$TEST_DIR/.mon_trap.sh" + sed '$d' "${BATS_TEST_DIRNAME}/../../ralph_monitor.sh" > "$_trap_src" local trap_output - trap_output=$(bash -c " - source <(head -n -1 '${BATS_TEST_DIRNAME}/../../ralph_monitor.sh') - trap -p EXIT - " 2>/dev/null) + trap_output=$(bash -c "source '$_trap_src'; trap -p EXIT" 2>/dev/null) echo "$trap_output" | grep -q "cleanup" || { echo "Expected EXIT trap to invoke cleanup; trap output: $trap_output" return 1 diff --git a/tests/integration/test_tmux_integration.bats b/tests/integration/test_tmux_integration.bats index d5007216..a1b2fd54 100644 --- a/tests/integration/test_tmux_integration.bats +++ b/tests/integration/test_tmux_integration.bats @@ -61,22 +61,29 @@ setup_tmux_session() { local ralph_home="${RALPH_HOME:-$HOME/.ralph}" local project_dir="$(pwd)" - # Get the tmux base-index / pane-base-index to handle custom configurations - local base_win base_pane - base_win=$(get_tmux_base_index) - base_pane=$(get_tmux_pane_base_index) - local pane0=$((base_pane + 0)) - local pane1=$((base_pane + 1)) - local pane2=$((base_pane + 2)) + # base-index / pane-base-index are detected AFTER the server starts (below). + # See ralph_loop.sh: querying earlier fails when no tmux server exists yet + # (first run) — `tmux show-options` does not auto-start a server, so detection + # silently defaults to 0. With a base-index 1 config that yields off-by-one + # pane targets and the loop never starts (tmux shows empty idle panes). Keep + # this inline mirror in sync with ralph_loop.sh. + local base_win base_pane pane0 pane1 pane2 log_status "INFO" "Setting up tmux session: $session_name" # Initialize live.log file echo "=== Ralph Live Output - Waiting for first loop... ===" > "$LIVE_LOG_FILE" - # Create new tmux session detached (left pane - Ralph loop) + # Create new tmux session detached (left pane - Ralph loop). Starts the server. tmux new-session -d -s "$session_name" -c "$project_dir" + # Detect base-index / pane-base-index now that the server is running. + base_win=$(get_tmux_base_index) + base_pane=$(get_tmux_pane_base_index) + pane0=$((base_pane + 0)) + pane1=$((base_pane + 1)) + pane2=$((base_pane + 2)) + # Split window vertically (right side) tmux split-window -h -t "$session_name" -c "$project_dir" @@ -348,6 +355,39 @@ assert_tmux_called_with() { assert_tmux_called_with "tmux show-options" } +# ============================================================================== +# TEST 3a: setup_tmux_session detects base-index AFTER starting the server +# Regression: with a base-index 1 / pane-base-index 1 config and no tmux server +# running yet (the first `ralph --monitor`), detecting BEFORE new-session made +# `tmux show-options` fail (it does not auto-start a server), silently defaulting +# detection to 0. Every pane/window target was then off-by-one, so the `ralph +# --live` loop command was sent to a nonexistent pane and tmux opened to empty +# idle panes. The fix detects AFTER `tmux new-session` starts the server. This +# test guards the call ORDER: the first `new-session` must precede the first +# `show-options` in the tmux call log. +# ============================================================================== +@test "setup_tmux_session detects base-index only after new-session starts the server" { + export MOCK_TMUX_BASE_INDEX="1" + export MOCK_TMUX_PANE_BASE_INDEX="1" + run setup_tmux_session + [ "$status" -eq 0 ] + + local new_session_line show_options_line + new_session_line=$(grep -nE '^tmux new-session -d' "$TMUX_CALL_LOG" | head -1 | cut -d: -f1) + show_options_line=$(grep -nE '^tmux show-options' "$TMUX_CALL_LOG" | head -1 | cut -d: -f1) + + [ -n "$new_session_line" ] || { echo "no new-session call logged:"; cat "$TMUX_CALL_LOG"; return 1; } + [ -n "$show_options_line" ] || { echo "no show-options call logged:"; cat "$TMUX_CALL_LOG"; return 1; } + + # The ordering invariant: server starts first, THEN base-index is queried. + if [ "$new_session_line" -ge "$show_options_line" ]; then + echo "FAIL: new-session (line $new_session_line) must precede show-options (line $show_options_line)" + echo "--- tmux call log ---" + cat "$TMUX_CALL_LOG" + return 1 + fi +} + # ============================================================================== # TEST 4: setup_tmux_session creates session with -d flag and ralph- prefix # ============================================================================== diff --git a/tests/unit/test_log_rotation.bats b/tests/unit/test_log_rotation.bats index 1a21ef47..6370e3f3 100644 --- a/tests/unit/test_log_rotation.bats +++ b/tests/unit/test_log_rotation.bats @@ -81,7 +81,10 @@ if [[ "\$1" == "-c%s" ]]; then fi if [[ "\$1" == "-f%z" ]]; then shift - exec "$real_stat" -c%s "\$@" + # Portable byte count — real stat's -c%s is GNU-only and fails on BSD/macOS, + # which silently defeated this fallback simulation there. wc -c works everywhere. + wc -c < "\$1" + exit 0 fi exec "$real_stat" "\$@" STUBEOF diff --git a/tests/unit/test_notifications.bats b/tests/unit/test_notifications.bats index b8137b8b..ba316761 100644 --- a/tests/unit/test_notifications.bats +++ b/tests/unit/test_notifications.bats @@ -155,6 +155,10 @@ EOF # ============================================================================= @test "send_notification uses notify-send on Linux when osascript unavailable" { + # osascript is always present on macOS (/usr/bin, and /bin symlinks to it), + # so the notify-send branch — which fires only when osascript is absent — is + # unreachable there and cannot be hidden via PATH. Exercise it on Linux only. + [[ "$(uname)" == "Darwin" ]] && skip "notify-send branch unreachable on macOS (osascript always present)" export ENABLE_NOTIFICATIONS=true # Create a private bin dir that has notify-send but NOT osascript diff --git a/tests/unit/test_ralph_enable.bats b/tests/unit/test_ralph_enable.bats index 8c7e0848..64c5fb78 100644 --- a/tests/unit/test_ralph_enable.bats +++ b/tests/unit/test_ralph_enable.bats @@ -294,8 +294,11 @@ EOF cd "'"$TEST_DIR"'" NON_INTERACTIVE=true - # Define phase_verification from ralph_enable.sh - source <(sed -n "/^phase_verification()/,/^}/p" "'"${BATS_TEST_DIRNAME}"'/../../ralph_enable.sh") + # Define phase_verification from ralph_enable.sh. Write to a temp file and + # source THAT — `source <(process-substitution)` does not define functions + # on macOS bash 3.2 (they silently stay undefined → "command not found"). + sed -n "/^phase_verification()/,/^}/p" "'"${BATS_TEST_DIRNAME}"'/../../ralph_enable.sh" > .pv.sh + source .pv.sh phase_verification ' @@ -323,8 +326,11 @@ EOF cd "'"$TEST_DIR"'" NON_INTERACTIVE=true - # Define phase_verification from ralph_enable.sh - source <(sed -n "/^phase_verification()/,/^}/p" "'"${BATS_TEST_DIRNAME}"'/../../ralph_enable.sh") + # Define phase_verification from ralph_enable.sh. Write to a temp file and + # source THAT — `source <(process-substitution)` does not define functions + # on macOS bash 3.2 (they silently stay undefined → "command not found"). + sed -n "/^phase_verification()/,/^}/p" "'"${BATS_TEST_DIRNAME}"'/../../ralph_enable.sh" > .pv.sh + source .pv.sh phase_verification '