|
| 1 | +#!/usr/bin/env bash |
| 2 | +# scripts/runners/bash-runner.sh — Bash test script runner driver |
| 3 | +# |
| 4 | +# Sourced by test-batched.sh to provide bash test script discovery and |
| 5 | +# execution. Discovers test-*.sh files under --test-dir and runs each |
| 6 | +# as a separate test item, enabling per-script resume on timeout. |
| 7 | +# |
| 8 | +# Requires these variables from the caller (test-batched.sh): |
| 9 | +# RUNNER — "bash" for explicit, "" for auto-detect |
| 10 | +# TEST_DIR — directory to search for test-*.sh files |
| 11 | +# COMPLETED_LIST — array of already-completed test IDs (for resume) |
| 12 | +# RESULTS_JSON — JSON object of results so far |
| 13 | +# STATE_FILE — path to the JSON state file |
| 14 | +# TIMEOUT — timeout in seconds |
| 15 | +# DEFAULT_TIMEOUT — default timeout value (for resume command construction) |
| 16 | +# CMD — fallback command (optional for bash runner) |
| 17 | +# |
| 18 | +# After sourcing, the caller checks USE_BASH_RUNNER and, if set, calls |
| 19 | +# _bash_runner_run to execute the bash runner path. |
| 20 | +# |
| 21 | +# Exports (set by this file): |
| 22 | +# USE_BASH_RUNNER — 1 if bash runner is active, 0 otherwise |
| 23 | +# BASH_FILES — array of discovered test script paths |
| 24 | + |
| 25 | +# _bash_discover_files <dir> |
| 26 | +# Prints one file path per line for test-*.sh files; returns non-zero if none found. |
| 27 | +_bash_discover_files() { |
| 28 | + local dir="$1" |
| 29 | + local found=0 |
| 30 | + # Use a while loop with sorted glob expansion for portability (no find -print0) |
| 31 | + while IFS= read -r f; do |
| 32 | + [ -f "$f" ] && [ -x "$f" ] && { echo "$f"; found=1; } |
| 33 | + done < <(find "$dir" -maxdepth 1 \( -name 'test-*.sh' -o -name 'run-*-tests.sh' \) -print 2>/dev/null | sort) |
| 34 | + [ "$found" -eq 1 ] |
| 35 | +} |
| 36 | + |
| 37 | +# Determine effective runner ────────────────────────────────────────────────── |
| 38 | +USE_BASH_RUNNER=0 |
| 39 | +BASH_FILES=() |
| 40 | + |
| 41 | +if [ "$RUNNER" = "bash" ]; then |
| 42 | + # Explicit --runner=bash: attempt bash driver; fall back on failures |
| 43 | + if [ -z "$TEST_DIR" ]; then |
| 44 | + echo "WARNING: --runner=bash requested but --test-dir not set; falling back to generic runner." >&2 |
| 45 | + else |
| 46 | + while IFS= read -r f; do |
| 47 | + BASH_FILES+=("$f") |
| 48 | + done < <(_bash_discover_files "$TEST_DIR" 2>/dev/null || true) |
| 49 | + |
| 50 | + if [ "${#BASH_FILES[@]}" -eq 0 ]; then |
| 51 | + echo "WARNING: --runner=bash: no test-*.sh or run-*-tests.sh files found under $TEST_DIR; falling back to generic runner." >&2 |
| 52 | + else |
| 53 | + USE_BASH_RUNNER=1 |
| 54 | + fi |
| 55 | + fi |
| 56 | +elif [ -z "$RUNNER" ] && [ -n "$TEST_DIR" ]; then |
| 57 | + # Auto-detect: activate bash driver when test-*.sh files exist under TEST_DIR |
| 58 | + # Only auto-detect if node and pytest didn't already claim the runner |
| 59 | + while IFS= read -r f; do |
| 60 | + BASH_FILES+=("$f") |
| 61 | + done < <(_bash_discover_files "$TEST_DIR" 2>/dev/null || true) |
| 62 | + |
| 63 | + if [ "${#BASH_FILES[@]}" -gt 0 ]; then |
| 64 | + USE_BASH_RUNNER=1 |
| 65 | + RUNNER="bash" |
| 66 | + fi |
| 67 | +fi |
| 68 | + |
| 69 | +# _bash_runner_run |
| 70 | +# Executes the bash runner path. Called by test-batched.sh when USE_BASH_RUNNER=1. |
| 71 | +# Uses all shared state variables from the caller. |
| 72 | +_bash_runner_run() { |
| 73 | + local TOTAL=${#BASH_FILES[@]} |
| 74 | + local START_TIME |
| 75 | + START_TIME=$(date +%s) |
| 76 | + # Preserve created_at from existing state (if resuming), otherwise use now. |
| 77 | + local SESSION_CREATED_AT="${_state_created_at:-$START_TIME}" |
| 78 | + _elapsed() { echo $(( $(date +%s) - START_TIME )); } |
| 79 | + local _bash_tmpdir |
| 80 | + _bash_tmpdir=$(mktemp -d /tmp/test-batched-bash-XXXXXX) |
| 81 | + local _existing_exit_trap |
| 82 | + _existing_exit_trap=$(trap -p EXIT | sed "s/^trap -- '//;s/' EXIT$//") |
| 83 | + if [ -n "$_existing_exit_trap" ]; then |
| 84 | + trap 'rm -rf "$_bash_tmpdir"; '"$_existing_exit_trap" EXIT |
| 85 | + else |
| 86 | + trap 'rm -rf "$_bash_tmpdir"' EXIT |
| 87 | + fi |
| 88 | + |
| 89 | + _save_state_and_resume_bash() { |
| 90 | + local completed_json results_json |
| 91 | + completed_json=$(_completed_to_json) |
| 92 | + results_json="$RESULTS_JSON" |
| 93 | + _state_write "$STATE_FILE" "bash:${TEST_DIR}" "$completed_json" "$results_json" "" "$SESSION_CREATED_AT" 2>/dev/null || { |
| 94 | + echo "WARNING: Could not write state file: $STATE_FILE" >&2 |
| 95 | + } |
| 96 | + local done_count=${#COMPLETED_LIST[@]} |
| 97 | + local resume_runner_arg="--runner=bash" |
| 98 | + local resume_dir_arg="--test-dir=${TEST_DIR}" |
| 99 | + local resume_timeout_arg="" |
| 100 | + [ "$TIMEOUT" -ne "$DEFAULT_TIMEOUT" ] && resume_timeout_arg="--timeout=$TIMEOUT " |
| 101 | + local resume_cmd="TEST_BATCHED_STATE_FILE=$STATE_FILE bash $0 ${resume_runner_arg} ${resume_dir_arg} ${resume_timeout_arg}${CMD:+"'$CMD'"}" |
| 102 | + echo "" |
| 103 | + echo "$done_count/$TOTAL tests completed." |
| 104 | + echo "" |
| 105 | + echo "════════════════════════════════════════════════════════════" |
| 106 | + echo " ⚠ ACTION REQUIRED — TESTS NOT COMPLETE ⚠" |
| 107 | + echo "════════════════════════════════════════════════════════════" |
| 108 | + echo "RUN: $resume_cmd" |
| 109 | + echo "DO NOT PROCEED until the command above prints a final summary." |
| 110 | + echo "════════════════════════════════════════════════════════════" |
| 111 | + exit 0 |
| 112 | + } |
| 113 | + |
| 114 | + for bash_file in "${BASH_FILES[@]}"; do |
| 115 | + # Use a path-relative test ID (relative to TEST_DIR) to avoid collisions |
| 116 | + # when two files share the same basename (even though -maxdepth 1 currently |
| 117 | + # prevents this, using a stable relative path makes the invariant explicit). |
| 118 | + # Portable: strip the TEST_DIR prefix from the absolute path. |
| 119 | + local test_id |
| 120 | + local _abs_bash_file _abs_test_dir |
| 121 | + _abs_bash_file="$(cd "$(dirname "$bash_file")" && pwd)/$(basename "$bash_file")" |
| 122 | + _abs_test_dir="$(cd "$TEST_DIR" && pwd)" |
| 123 | + test_id="${_abs_bash_file#"${_abs_test_dir}/"}" |
| 124 | + # Fallback to basename if prefix stripping produced an empty or unchanged result |
| 125 | + [ -z "$test_id" ] || [ "$test_id" = "$_abs_bash_file" ] && test_id="$(basename "$bash_file")" |
| 126 | + |
| 127 | + if _is_completed "$test_id"; then |
| 128 | + echo "Skipping (already completed): $test_id" |
| 129 | + continue |
| 130 | + fi |
| 131 | + |
| 132 | + # Check timeout before running this file |
| 133 | + if [ "$(_elapsed)" -ge "$TIMEOUT" ]; then |
| 134 | + _save_state_and_resume_bash |
| 135 | + fi |
| 136 | + |
| 137 | + echo "Running: bash $bash_file" |
| 138 | + |
| 139 | + # Launch the test script as a direct background child. Exit-code capture |
| 140 | + # uses `wait <pid>` — which is synchronous and race-free — instead of the |
| 141 | + # previous approach of writing "$?" to a file from inside a subshell and then |
| 142 | + # reading it from the parent (which could race with file-system buffering on |
| 143 | + # busy or network-mounted filesystems). |
| 144 | + local bash_exit=0 |
| 145 | + bash "$bash_file" & |
| 146 | + local _test_bg_pid=$! |
| 147 | + |
| 148 | + # Monitor: poll until the test finishes or the time budget runs out. |
| 149 | + while kill -0 "$_test_bg_pid" 2>/dev/null; do |
| 150 | + if [ "$(_elapsed)" -ge "$TIMEOUT" ]; then |
| 151 | + kill "$_test_bg_pid" 2>/dev/null || true |
| 152 | + wait "$_test_bg_pid" 2>/dev/null || true |
| 153 | + COMPLETED_LIST+=("$test_id") |
| 154 | + RESULTS_JSON=$(_results_add "$RESULTS_JSON" "$test_id" "interrupted") |
| 155 | + _save_state_and_resume_bash |
| 156 | + fi |
| 157 | + sleep 0.1 2>/dev/null || sleep 1 |
| 158 | + done |
| 159 | + |
| 160 | + # `wait` on a direct child always returns the child's actual exit code — |
| 161 | + # no file-write race is possible here. |
| 162 | + wait "$_test_bg_pid" 2>/dev/null; bash_exit=$? |
| 163 | + |
| 164 | + local bash_outcome |
| 165 | + if [ "$bash_exit" -eq 0 ]; then |
| 166 | + bash_outcome="pass" |
| 167 | + else |
| 168 | + bash_outcome="fail" |
| 169 | + fi |
| 170 | + |
| 171 | + COMPLETED_LIST+=("$test_id") |
| 172 | + RESULTS_JSON=$(_results_add "$RESULTS_JSON" "$test_id" "$bash_outcome") |
| 173 | + |
| 174 | + local done_count=${#COMPLETED_LIST[@]} |
| 175 | + echo "$done_count/$TOTAL tests completed." |
| 176 | + done |
| 177 | + |
| 178 | + # All bash files processed — print summary |
| 179 | + local pass_count fail_count interrupted_count total_done |
| 180 | + pass_count=$(_results_count "$RESULTS_JSON" "pass") |
| 181 | + fail_count=$(_results_count "$RESULTS_JSON" "fail") |
| 182 | + interrupted_count=$(_results_count "$RESULTS_JSON" "interrupted") |
| 183 | + total_done=${#COMPLETED_LIST[@]} |
| 184 | + |
| 185 | + echo "" |
| 186 | + echo "All tests done. $total_done/$TOTAL tests completed. $pass_count passed, $fail_count failed, $interrupted_count interrupted." |
| 187 | + |
| 188 | + if [ "$fail_count" -gt 0 ]; then |
| 189 | + echo "" |
| 190 | + echo "Failures:" |
| 191 | + _results_failures "$RESULTS_JSON" | while IFS= read -r fid; do |
| 192 | + echo " FAIL: $fid" |
| 193 | + done |
| 194 | + fi |
| 195 | + |
| 196 | + rm -f "$STATE_FILE" |
| 197 | + # Interrupted tests are non-passing — exit non-zero if any tests failed or were interrupted |
| 198 | + [ "$fail_count" -gt 0 ] || [ "$interrupted_count" -gt 0 ] && exit 1 || exit 0 |
| 199 | +} |
0 commit comments