#!/usr/bin/env bash # check_os.sh # shellcheck disable=SC2034 _BASHUNIT_OS="Unknown" _BASHUNIT_DISTRO="Unknown" function bashunit::check_os::init() { _BASHUNIT_UNAME="$(uname)" if bashunit::check_os::is_linux; then _BASHUNIT_OS="Linux" if bashunit::check_os::is_ubuntu; then _BASHUNIT_DISTRO="Ubuntu" elif bashunit::check_os::is_alpine; then _BASHUNIT_DISTRO="Alpine" elif bashunit::check_os::is_nixos; then _BASHUNIT_DISTRO="NixOS" else _BASHUNIT_DISTRO="Other" fi elif bashunit::check_os::is_macos; then _BASHUNIT_OS="OSX" elif bashunit::check_os::is_windows; then _BASHUNIT_OS="Windows" else _BASHUNIT_OS="Unknown" _BASHUNIT_DISTRO="Unknown" fi } function bashunit::check_os::is_ubuntu() { command -v apt >/dev/null 2>&1 } function bashunit::check_os::is_alpine() { command -v apk >/dev/null 2>&1 } function bashunit::check_os::is_nixos() { [ -f /etc/NIXOS ] && return 0 grep -q '^ID=nixos' /etc/os-release 2>/dev/null } function bashunit::check_os::is_linux() { [ "$_BASHUNIT_UNAME" = "Linux" ] } function bashunit::check_os::is_macos() { [ "$_BASHUNIT_UNAME" = "Darwin" ] } function bashunit::check_os::is_windows() { case "$_BASHUNIT_UNAME" in *MINGW* | *MSYS* | *CYGWIN*) return 0 ;; *) return 1 ;; esac } ## # Detects the number of online CPU cores, portably across Linux/macOS/BSD. # Tries nproc, then sysctl, then getconf; falls back to 4 when none report a # usable positive integer. Takes the first whitespace-delimited token so a # stray flag or trailing text never poisons the arithmetic guard. # Returns: prints the core count (>= 1) to stdout. ## function bashunit::check_os::nproc() { local cores="" cores="$(nproc 2>/dev/null)" || cores="" if [ -z "$cores" ]; then cores="$(sysctl -n hw.ncpu 2>/dev/null)" || cores="" fi if [ -z "$cores" ]; then cores="$(getconf _NPROCESSORS_ONLN 2>/dev/null)" || cores="" fi cores="${cores%% *}" case "$cores" in '' | *[!0-9]*) cores=4 ;; esac [ "$cores" -lt 1 ] && cores=4 echo "$cores" } function bashunit::check_os::is_busybox() { case "$_BASHUNIT_DISTRO" in "Alpine") return 0 ;; *) return 1 ;; esac } bashunit::check_os::init export _BASHUNIT_OS export _BASHUNIT_DISTRO export -f bashunit::check_os::nproc export -f bashunit::check_os::is_alpine export -f bashunit::check_os::is_busybox export -f bashunit::check_os::is_ubuntu export -f bashunit::check_os::is_nixos # str.sh _BASHUNIT_STR_STRIPPED_OUT="" # Strip ANSI escape codes and control characters, writing the result into the # global slot _BASHUNIT_STR_STRIPPED_OUT (no fork on the plain-text fast path). # Callers on hot paths (assert_equals/assert_not_equals) use this to avoid the # per-call command-substitution fork. See bash-style.md (return-slot pattern). function bashunit::str::strip_ansi_to_slot() { local input="$1" # Fast path: plain text with no backslash (echo -e no-op) and no control # bytes (nothing for sed to strip) passes through unchanged, zero forks. case "$input" in *\\* | *[[:cntrl:]]*) ;; *) _BASHUNIT_STR_STRIPPED_OUT=$input return ;; esac # Pure-bash path for short strings without backslashes: display lines (e.g. # the per-test "✓ Passed … 3ms" alignment on systems whose clock is fork-free) # land here, so a colored line does not cost a sed fork per test. Strip # CSI sequences segment-wise, then sweep remaining control bytes; the size # guard avoids bash's quadratic pattern-substitution on large captures and # `*\\*` still defers to `echo -e` semantics below. case "$input" in *\\*) ;; *) if [ "${#input}" -le 1024 ]; then local out="" rest="$input" params while :; do case "$rest" in *$'\x1b'\[*) out="$out${rest%%$'\x1b'\[*}" rest="${rest#*$'\x1b'\[}" params="" while :; do case "$rest" in [0-9\;]*) params="$params${rest%"${rest#?}"}" rest="${rest#?}" ;; *) break ;; esac done case "$rest" in # Same finals sed strips; anything else keeps its printable residue # (the ESC itself falls to the control-byte sweep, exactly like sed). m* | K*) rest="${rest#?}" ;; *) out="${out}[${params}" ;; esac ;; *) out="$out$rest" break ;; esac done _BASHUNIT_STR_STRIPPED_OUT=${out//[[:cntrl:]]/} return fi ;; esac _BASHUNIT_STR_STRIPPED_OUT=$(echo -e "$input" | sed -E 's/\x1B\[[0-9;]*[mK]//g; s/[[:cntrl:]]//g') } # Strip ANSI escape codes and control characters, echoing the result. # Thin wrapper over the return-slot variant for callers that want stdout. function bashunit::str::strip_ansi() { bashunit::str::strip_ansi_to_slot "$1" echo "$_BASHUNIT_STR_STRIPPED_OUT" } function bashunit::str::rpad() { local left_text="$1" local right_word="$2" local width_padding="${3:-$TERMINAL_WIDTH}" # Subtract 1 more to account for the extra space local padding=$((width_padding - ${#right_word} - 1)) if ((padding < 0)); then padding=0 fi # Remove ANSI escape sequences (non-visible characters) for length calculation bashunit::str::strip_ansi_to_slot "$left_text" local clean_left_text=$_BASHUNIT_STR_STRIPPED_OUT local is_truncated=false # If the visible left text exceeds the padding, truncate it and add "..." if [ ${#clean_left_text} -gt $padding ]; then local truncation_length=$((padding < 3 ? 0 : padding - 3)) clean_left_text="${clean_left_text:0:$truncation_length}" is_truncated=true fi local result_left_text local remaining_space if $is_truncated; then # Rebuild char-by-char with ANSI codes intact, applying the truncation. result_left_text="" local i=0 local j=0 while [ $i -lt ${#clean_left_text} ] && [ $j -lt ${#left_text} ]; do local char="${clean_left_text:$i:1}" local original_char="${left_text:$j:1}" # If the current character is part of an ANSI sequence, skip it and copy it if [ "$original_char" = $'\x1b' ]; then while [ "${left_text:$j:1}" != "m" ] && [ $j -lt ${#left_text} ]; do result_left_text="$result_left_text${left_text:$j:1}" ((++j)) done result_left_text="$result_left_text${left_text:$j:1}" # Append the final 'm' ((++j)) elif [ "$char" = "$original_char" ]; then # Match the actual character result_left_text="$result_left_text$char" ((++i)) ((++j)) else ((++j)) fi done result_left_text="$result_left_text..." # 1: due to a blank space # 3: due to the appended ... remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1 - 3)) else # Not truncated: the visible text fits, so the original (ANSI intact) is # already correct — skip the per-character rebuild entirely. result_left_text="$left_text" remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1)) fi # Ensure the right word is placed exactly at the far right of the screen # filling the remaining space with padding if [ $remaining_space -lt 0 ]; then remaining_space=0 fi printf "%s%${remaining_space}s %s\n" "$result_left_text" "" "$right_word" } # globals.sh set -euo pipefail # This file provides a set of global functions to developers. function bashunit::current_dir() { dirname "${BASH_SOURCE[1]}" } function bashunit::current_filename() { basename "${BASH_SOURCE[1]}" } function bashunit::caller_filename() { dirname "${BASH_SOURCE[2]}" } function bashunit::caller_line() { echo "${BASH_LINENO[1]}" } function bashunit::current_timestamp() { date +"%Y-%m-%d %H:%M:%S" } function bashunit::is_command_available() { command -v "$1" >/dev/null 2>&1 } function bashunit::random_str() { local length=${1:-6} local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' local str='' local i for ((i = 0; i < length; i++)); do str="$str${chars:RANDOM%${#chars}:1}" done echo "$str" } function bashunit::temp_file() { local prefix=${1:-bashunit} local test_prefix="" if [ -n "${BASHUNIT_CURRENT_TEST_ID:-}" ]; then # We're inside a test function - use test ID test_prefix="${BASHUNIT_CURRENT_TEST_ID}_" elif [ -n "${BASHUNIT_CURRENT_SCRIPT_ID:-}" ]; then # We're at script level (e.g., in set_up_before_script) - use script ID test_prefix="${BASHUNIT_CURRENT_SCRIPT_ID}_" fi "$MKTEMP" "$BASHUNIT_TEMP_DIR/${test_prefix}${prefix}.XXXXXXX" } function bashunit::temp_dir() { local prefix=${1:-bashunit} local test_prefix="" if [ -n "${BASHUNIT_CURRENT_TEST_ID:-}" ]; then # We're inside a test function - use test ID test_prefix="${BASHUNIT_CURRENT_TEST_ID}_" elif [ -n "${BASHUNIT_CURRENT_SCRIPT_ID:-}" ]; then # We're at script level (e.g., in set_up_before_script) - use script ID test_prefix="${BASHUNIT_CURRENT_SCRIPT_ID}_" fi "$MKTEMP" -d "$BASHUNIT_TEMP_DIR/${test_prefix}${prefix}.XXXXXXX" } function bashunit::cleanup_testcase_temp_files() { bashunit::internal_log "cleanup_testcase_temp_files" if [ -n "${BASHUNIT_CURRENT_TEST_ID:-}" ]; then # Probe the glob in pure bash first: most tests create no temp file, so # skipping the rm avoids a fork per test (#764). A non-matching glob either # stays literal (nullglob off) or yields an empty array (nullglob on); # ${matches[0]:-} handles both under set -u, and [ -e ] is false in each # case. temp_file runs in a $(...) subshell, so a global flag could not # reach this trap; checking the filesystem is the only reliable signal. # Declare and assign separately: bash 3.0 does not expand a compound array # assignment attached to `local`, it stores the literal "(glob)" instead. local matches matches=("$BASHUNIT_TEMP_DIR/${BASHUNIT_CURRENT_TEST_ID}"_*) # if-form, not `[ ] && rm`: as the function's last statement the skip path # would return 1, which is a death sentence for callers under set -e (#836) if [ -e "${matches[0]:-}" ]; then rm -rf "${matches[@]}" fi fi } function bashunit::cleanup_script_temp_files() { bashunit::internal_log "cleanup_script_temp_files" if [ -n "${BASHUNIT_CURRENT_SCRIPT_ID:-}" ]; then rm -rf "$BASHUNIT_TEMP_DIR/${BASHUNIT_CURRENT_SCRIPT_ID}"_* fi } # shellcheck disable=SC2145 function bashunit::log() { if ! bashunit::env::is_dev_mode_enabled; then return fi local level="$1" shift case "$level" in info | INFO) level="INFO" ;; debug | DEBUG) level="DEBUG" ;; warning | WARNING) level="WARNING" ;; critical | CRITICAL) level="CRITICAL" ;; error | ERROR) level="ERROR" ;; *) set -- "$level $@" level="INFO" ;; esac echo "$(bashunit::current_timestamp) [$level]: $* #${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >>"$BASHUNIT_DEV_LOG" } function bashunit::internal_log() { if ! bashunit::env::is_dev_mode_enabled || ! bashunit::env::is_internal_log_enabled; then return fi echo "$(bashunit::current_timestamp) [INTERNAL]: $* #${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >>"$BASHUNIT_DEV_LOG" } function bashunit::print_line() { local length="${1:-70}" # Default to 70 if not passed local char="${2:--}" # Default to '-' if not passed printf '%*s\n' "$length" '' | tr ' ' "$char" } function bashunit::data_set() { local arg local first=true for arg in "$@"; do if [ "$first" = true ]; then # Bash 3.0 compatible: printf '%q' "" produces nothing in Bash 3.0 if [ -z "$arg" ]; then printf "''" else printf '%q' "$arg" fi first=false else if [ -z "$arg" ]; then printf " ''" else printf ' %q' "$arg" fi fi done # Sentinel empty string at end printf " ''\n" } # dependencies.sh set -euo pipefail function bashunit::dependencies::has_perl() { command -v perl >/dev/null 2>&1 } function bashunit::dependencies::has_powershell() { command -v powershell >/dev/null 2>&1 } function bashunit::dependencies::has_adjtimex() { command -v adjtimex >/dev/null 2>&1 } function bashunit::dependencies::has_bc() { command -v bc >/dev/null 2>&1 } function bashunit::dependencies::has_awk() { command -v awk >/dev/null 2>&1 } function bashunit::dependencies::has_git() { command -v git >/dev/null 2>&1 } function bashunit::dependencies::has_curl() { command -v curl >/dev/null 2>&1 } function bashunit::dependencies::has_wget() { command -v wget >/dev/null 2>&1 } function bashunit::dependencies::has_python() { command -v python >/dev/null 2>&1 } function bashunit::dependencies::has_node() { command -v node >/dev/null 2>&1 } function bashunit::dependencies::has_tput() { command -v tput >/dev/null 2>&1 } # io.sh ## # Clear the terminal screen and move the cursor to the home position. # Uses `tput clear` when available (queries terminfo for the right sequence) # and falls back to the ANSI sequence \033[2J\033[H otherwise. ## function bashunit::io::clear_screen() { if bashunit::dependencies::has_tput; then local out out=$(tput clear 2>/dev/null) if [ -n "$out" ]; then printf '%s' "$out" return fi fi printf '\033[2J\033[H' } function bashunit::io::download_to() { local url="$1" local output="$2" if bashunit::dependencies::has_curl; then curl -fsSL -o "$output" "$url" elif bashunit::dependencies::has_wget; then wget -q -O "$output" "$url" else echo "no curl or wget available" >&2 return 1 fi } # math.sh function bashunit::math::calculate() { local expr="$*" if bashunit::dependencies::has_bc; then echo "$expr" | bc return fi case "$expr" in *.*) if bashunit::dependencies::has_awk; then awk "BEGIN { print ($expr) }" return fi # Downgrade to integer math by stripping decimals expr=$(echo "$expr" | sed -E 's/([0-9]+)\.[0-9]+/\1/g') ;; esac # Remove leading zeros from integers so $((...)) does not read them as octal. # Only fork sed when a leading zero is actually present — the common callers # (clock durations) never produce one, so the no-bc path stays fork-free. case "$expr" in 0[0-9]* | *[!0-9.]0[0-9]*) expr=$(echo "$expr" | sed -E 's/\b0*([1-9][0-9]*)/\1/g') ;; esac local result=$((expr)) echo "$result" } ## # Deterministically shuffles stdin lines (one item per line) with a Fisher-Yates # driven by a seeded LCG (glibc constants). Same seed + same input always yields # the same permutation, so a randomized run can be replayed via its seed. # Self-contained (seeds a local state), so it is safe inside subshells/pipes and # in --parallel where each test file shuffles in its own forked shell. # Arguments: $1 - integer seed (non-numeric treated as 0) ## function bashunit::math::shuffle() { local seed=$1 case "$seed" in '' | *[!0-9]*) seed=0 ;; esac local state=$((seed & 2147483647)) local -a items=() local n=0 local line # `|| [ -n "$line" ]` keeps the final item when stdin has no trailing newline. while IFS= read -r line || [ -n "$line" ]; do items[n]=$line n=$((n + 1)) done local i j tmp i=$((n - 1)) while [ "$i" -gt 0 ]; do state=$(((1103515245 * state + 12345) & 2147483647)) j=$((state % (i + 1))) tmp=${items[i]} items[i]=${items[j]} items[j]=$tmp i=$((i - 1)) done local k=0 while [ "$k" -lt "$n" ]; do printf '%s\n' "${items[k]}" k=$((k + 1)) done } # parallel.sh function bashunit::parallel::aggregate_test_results() { local temp_dir_parallel_test_suite=$1 local IFS=$' \t\n' bashunit::internal_log "aggregate_test_results" "dir:$temp_dir_parallel_test_suite" local total_failed=0 local total_passed=0 local total_skipped=0 local total_incomplete=0 local total_snapshot=0 local script_dir="" for script_dir in "$temp_dir_parallel_test_suite"/*; do shopt -s nullglob # Bash 3.0 compatible: separate declaration and assignment for arrays local result_files result_files=("$script_dir"/*.result) shopt -u nullglob if [ ${#result_files[@]} -eq 0 ]; then printf "%sNo tests found%s" "$_BASHUNIT_COLOR_SKIPPED" "$_BASHUNIT_COLOR_DEFAULT" continue fi local result_file="" for result_file in "${result_files[@]+"${result_files[@]}"}"; do local result_line result_line=$(<"$result_file") result_line="${result_line##*$'\n'}" local failed="${result_line##*##ASSERTIONS_FAILED=}" failed="${failed%%##*}" failed=${failed:-0} local passed="${result_line##*##ASSERTIONS_PASSED=}" passed="${passed%%##*}" passed=${passed:-0} local skipped="${result_line##*##ASSERTIONS_SKIPPED=}" skipped="${skipped%%##*}" skipped=${skipped:-0} local incomplete="${result_line##*##ASSERTIONS_INCOMPLETE=}" incomplete="${incomplete%%##*}" incomplete=${incomplete:-0} local snapshot="${result_line##*##ASSERTIONS_SNAPSHOT=}" snapshot="${snapshot%%##*}" snapshot=${snapshot:-0} local exit_code="${result_line##*##TEST_EXIT_CODE=}" exit_code="${exit_code%%##*}" exit_code=${exit_code:-0} # Add to the total counts total_failed=$((total_failed + failed)) total_passed=$((total_passed + passed)) total_skipped=$((total_skipped + skipped)) total_incomplete=$((total_incomplete + incomplete)) total_snapshot=$((total_snapshot + snapshot)) if [ "${failed:-0}" -gt 0 ]; then bashunit::state::add_tests_failed continue fi if [ "${exit_code:-0}" -ne 0 ]; then bashunit::state::add_tests_failed continue fi if [ "${snapshot:-0}" -gt 0 ]; then bashunit::state::add_tests_snapshot continue fi if [ "${incomplete:-0}" -gt 0 ]; then bashunit::state::add_tests_incomplete continue fi if [ "${skipped:-0}" -gt 0 ]; then bashunit::state::add_tests_skipped continue fi # Check for risky test (zero assertions, no error) local total_for_test=$((failed + passed + skipped + incomplete + snapshot)) if [ "$total_for_test" -eq 0 ] && [ "${exit_code:-0}" -eq 0 ]; then if bashunit::env::is_fail_on_risky_enabled; then bashunit::state::add_tests_failed else bashunit::state::add_tests_risky fi continue fi bashunit::state::add_tests_passed done done export _BASHUNIT_ASSERTIONS_FAILED=$total_failed export _BASHUNIT_ASSERTIONS_PASSED=$total_passed export _BASHUNIT_ASSERTIONS_SKIPPED=$total_skipped export _BASHUNIT_ASSERTIONS_INCOMPLETE=$total_incomplete export _BASHUNIT_ASSERTIONS_SNAPSHOT=$total_snapshot bashunit::internal_log "aggregate_totals" \ "failed:$total_failed" \ "passed:$total_passed" \ "skipped:$total_skipped" \ "incomplete:$total_incomplete" \ "snapshot:$total_snapshot" } function bashunit::parallel::mark_stop_on_failure() { touch "$TEMP_FILE_PARALLEL_STOP_ON_FAILURE" } function bashunit::parallel::must_stop_on_failure() { [ -f "$TEMP_FILE_PARALLEL_STOP_ON_FAILURE" ] } function bashunit::parallel::cleanup() { # shellcheck disable=SC2153 local target="$TEMP_DIR_PARALLEL_TEST_SUITE" case "$target" in */bashunit/parallel/*) rm -rf "$target" ;; *) bashunit::internal_log "parallel::cleanup" "refused unsafe path:$target" return 1 ;; esac } function bashunit::parallel::init() { bashunit::parallel::cleanup mkdir -p "$TEMP_DIR_PARALLEL_TEST_SUITE" } # Cached result of resolve_enabled ("true"/"false"); empty until resolved. _BASHUNIT_PARALLEL_ENABLED="" # Pure predicate: parallel requested AND running on a supported OS. No caching # or logging, so it is safe to call fresh (e.g. from unit tests). function bashunit::parallel::_compute_enabled() { bashunit::env::is_parallel_run_enabled && (bashunit::check_os::is_macos || bashunit::check_os::is_ubuntu || bashunit::check_os::is_alpine || bashunit::check_os::is_windows) } # Resolve parallel mode once (after arg parsing) into _BASHUNIT_PARALLEL_ENABLED # so is_enabled becomes a pure global read on the per-test hot path. The env + # OS checks are constant for the whole run. function bashunit::parallel::resolve_enabled() { if bashunit::parallel::_compute_enabled; then _BASHUNIT_PARALLEL_ENABLED=true else _BASHUNIT_PARALLEL_ENABLED=false fi bashunit::internal_log "bashunit::parallel::resolve_enabled" \ "requested:$BASHUNIT_PARALLEL_RUN" "os:${_BASHUNIT_OS:-Unknown}" \ "enabled:$_BASHUNIT_PARALLEL_ENABLED" } function bashunit::parallel::is_enabled() { case "$_BASHUNIT_PARALLEL_ENABLED" in true) return 0 ;; false) return 1 ;; esac # Not resolved yet (e.g. unit tests call is_enabled directly): compute fresh # without caching so per-test env/OS changes are still honoured. bashunit::parallel::_compute_enabled } # env.sh # shellcheck disable=SC2034 ## # Loads a project config file of `KEY=value` lines (comments with `#` and blank # lines are ignored). Each key is only applied when not already set in the # environment, so real env vars and CLI flags keep precedence over the file. # Surrounding single/double quotes and an optional `export ` prefix are stripped. # Arguments: $1 path to the config file ## function bashunit::env::load_config_file() { local file=$1 [ -f "$file" ] || return 0 local line key val while IFS= read -r line || [ -n "$line" ]; do # Trim leading whitespace line=${line#"${line%%[![:space:]]*}"} case "$line" in '' | '#'*) continue ;; esac case "$line" in export\ *) line=${line#export } ;; esac case "$line" in *=*) ;; *) continue ;; esac key=${line%%=*} val=${line#*=} # Only accept valid shell identifiers (defends the eval below) case "$key" in '' | *[!A-Za-z0-9_]* | [0-9]*) continue ;; esac # Strip surrounding matching quotes case "$val" in \"*\") val=${val#\"} val=${val%\"} ;; \'*\') val=${val#\'} val=${val%\'} ;; esac # Apply only when unset: env var / CLI flag > config file eval "export $key=\"\${$key:-\$val}\"" done <"$file" } ## # Echoes $1 when it is a positive integer, otherwise echoes the default $2. # Arguments: $1 candidate value, $2 fallback default ## function bashunit::env::positive_int_or_default() { local value="$1" local default="$2" case "$value" in '' | *[!0-9]* | 0) echo "$default" ;; *) echo "$value" ;; esac } # Load project config (lower precedence than env vars, .env and CLI flags). # Load .env file (skip if --skip-env-file is used to keep shell environment intact) if [ "${BASHUNIT_SKIP_ENV_FILE:-false}" != "true" ]; then bashunit::env::load_config_file ".bashunitrc" set -o allexport # shellcheck source=/dev/null [ -f ".env" ] && source .env set +o allexport fi _BASHUNIT_DEFAULT_DEFAULT_PATH="tests" _BASHUNIT_DEFAULT_BOOTSTRAP="tests/bootstrap.sh" _BASHUNIT_DEFAULT_DEV_LOG="" _BASHUNIT_DEFAULT_LOG_JUNIT="" _BASHUNIT_DEFAULT_LOG_GHA="" _BASHUNIT_DEFAULT_REPORT_HTML="" _BASHUNIT_DEFAULT_REPORT_TAP="" _BASHUNIT_DEFAULT_REPORT_JSON="" # Coverage defaults (following kcov, bashcov, SimpleCov conventions) _BASHUNIT_DEFAULT_COVERAGE="false" _BASHUNIT_DEFAULT_COVERAGE_PATHS="" _BASHUNIT_DEFAULT_COVERAGE_EXCLUDE="tests/*,vendor/*,*_test.sh,*Test.sh" _BASHUNIT_DEFAULT_COVERAGE_REPORT="coverage/lcov.info" _BASHUNIT_DEFAULT_COVERAGE_REPORT_HTML="" _BASHUNIT_DEFAULT_COVERAGE_MIN="" _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW="50" _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH="80" : "${BASHUNIT_DEFAULT_PATH:=${DEFAULT_PATH:=$_BASHUNIT_DEFAULT_DEFAULT_PATH}}" : "${BASHUNIT_DEV_LOG:=${DEV_LOG:=$_BASHUNIT_DEFAULT_DEV_LOG}}" : "${BASHUNIT_BOOTSTRAP:=${BOOTSTRAP:=$_BASHUNIT_DEFAULT_BOOTSTRAP}}" : "${BASHUNIT_BOOTSTRAP_ARGS:=${BOOTSTRAP_ARGS:=}}" : "${BASHUNIT_LOG_JUNIT:=${LOG_JUNIT:=$_BASHUNIT_DEFAULT_LOG_JUNIT}}" : "${BASHUNIT_LOG_GHA:=${LOG_GHA:=$_BASHUNIT_DEFAULT_LOG_GHA}}" : "${BASHUNIT_REPORT_HTML:=${REPORT_HTML:=$_BASHUNIT_DEFAULT_REPORT_HTML}}" : "${BASHUNIT_REPORT_TAP:=${REPORT_TAP:=$_BASHUNIT_DEFAULT_REPORT_TAP}}" : "${BASHUNIT_REPORT_JSON:=${REPORT_JSON:=$_BASHUNIT_DEFAULT_REPORT_JSON}}" # Watch mode polling interval (seconds) used by the pure-shell fallback _BASHUNIT_DEFAULT_WATCH_INTERVAL="2" : "${BASHUNIT_WATCH_INTERVAL:=${WATCH_INTERVAL:=$_BASHUNIT_DEFAULT_WATCH_INTERVAL}}" BASHUNIT_WATCH_INTERVAL=$(bashunit::env::positive_int_or_default \ "$BASHUNIT_WATCH_INTERVAL" "$_BASHUNIT_DEFAULT_WATCH_INTERVAL") # Coverage : "${BASHUNIT_COVERAGE:=${COVERAGE:=$_BASHUNIT_DEFAULT_COVERAGE}}" : "${BASHUNIT_COVERAGE_PATHS:=${COVERAGE_PATHS:=$_BASHUNIT_DEFAULT_COVERAGE_PATHS}}" : "${BASHUNIT_COVERAGE_EXCLUDE:=${COVERAGE_EXCLUDE:=$_BASHUNIT_DEFAULT_COVERAGE_EXCLUDE}}" : "${BASHUNIT_COVERAGE_REPORT:=${COVERAGE_REPORT:=$_BASHUNIT_DEFAULT_COVERAGE_REPORT}}" : "${BASHUNIT_COVERAGE_REPORT_HTML:=${COVERAGE_REPORT_HTML:=$_BASHUNIT_DEFAULT_COVERAGE_REPORT_HTML}}" : "${BASHUNIT_COVERAGE_MIN:=${COVERAGE_MIN:=$_BASHUNIT_DEFAULT_COVERAGE_MIN}}" : "${BASHUNIT_COVERAGE_THRESHOLD_LOW:=${COVERAGE_THRESHOLD_LOW:=$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}}" : "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:=${COVERAGE_THRESHOLD_HIGH:=$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}}" # Booleans _BASHUNIT_DEFAULT_PARALLEL_RUN="false" _BASHUNIT_DEFAULT_SHOW_HEADER="true" _BASHUNIT_DEFAULT_HEADER_ASCII_ART="false" _BASHUNIT_DEFAULT_SIMPLE_OUTPUT="false" _BASHUNIT_DEFAULT_STOP_ON_FAILURE="false" # "auto" shows per-test times only when the clock is fork-free (#765). _BASHUNIT_DEFAULT_SHOW_EXECUTION_TIME="auto" _BASHUNIT_DEFAULT_VERBOSE="false" _BASHUNIT_DEFAULT_BENCH_MODE="false" _BASHUNIT_DEFAULT_NO_OUTPUT="false" _BASHUNIT_DEFAULT_INTERNAL_LOG="false" _BASHUNIT_DEFAULT_SHOW_SKIPPED="false" _BASHUNIT_DEFAULT_SHOW_INCOMPLETE="false" _BASHUNIT_DEFAULT_STRICT_MODE="false" _BASHUNIT_DEFAULT_STOP_ON_ASSERTION_FAILURE="true" _BASHUNIT_DEFAULT_SKIP_ENV_FILE="false" _BASHUNIT_DEFAULT_LOGIN_SHELL="false" _BASHUNIT_DEFAULT_FAILURES_ONLY="false" _BASHUNIT_DEFAULT_NO_COLOR="false" _BASHUNIT_DEFAULT_NO_DIFF="false" _BASHUNIT_DEFAULT_SHOW_OUTPUT_ON_FAILURE="true" _BASHUNIT_DEFAULT_NO_PROGRESS="false" _BASHUNIT_DEFAULT_OUTPUT_FORMAT="" _BASHUNIT_DEFAULT_FAIL_ON_RISKY="false" _BASHUNIT_DEFAULT_PROFILE="false" _BASHUNIT_DEFAULT_PROFILE_COUNT="10" # Per-test timeout in seconds (0 = disabled) _BASHUNIT_DEFAULT_TEST_TIMEOUT="0" # Extra attempts for a failed test (0 = no retry) _BASHUNIT_DEFAULT_RETRY="0" # Randomize test execution order to surface inter-test coupling _BASHUNIT_DEFAULT_RANDOM_ORDER="false" # Seed for --random-order (empty = generate one and print it) _BASHUNIT_DEFAULT_SEED="" # Shard / to split the suite across runners (empty = disabled) _BASHUNIT_DEFAULT_SHARD_INDEX="" _BASHUNIT_DEFAULT_SHARD_TOTAL="" : "${BASHUNIT_PARALLEL_RUN:=${PARALLEL_RUN:=$_BASHUNIT_DEFAULT_PARALLEL_RUN}}" : "${BASHUNIT_PARALLEL_JOBS:=0}" : "${BASHUNIT_SHOW_HEADER:=${SHOW_HEADER:=$_BASHUNIT_DEFAULT_SHOW_HEADER}}" : "${BASHUNIT_HEADER_ASCII_ART:=${HEADER_ASCII_ART:=$_BASHUNIT_DEFAULT_HEADER_ASCII_ART}}" : "${BASHUNIT_SIMPLE_OUTPUT:=${SIMPLE_OUTPUT:=$_BASHUNIT_DEFAULT_SIMPLE_OUTPUT}}" : "${BASHUNIT_STOP_ON_FAILURE:=${STOP_ON_FAILURE:=$_BASHUNIT_DEFAULT_STOP_ON_FAILURE}}" : "${BASHUNIT_SHOW_EXECUTION_TIME:=${SHOW_EXECUTION_TIME:=$_BASHUNIT_DEFAULT_SHOW_EXECUTION_TIME}}" : "${BASHUNIT_VERBOSE:=${VERBOSE:=$_BASHUNIT_DEFAULT_VERBOSE}}" : "${BASHUNIT_BENCH_MODE:=${BENCH_MODE:=$_BASHUNIT_DEFAULT_BENCH_MODE}}" : "${BASHUNIT_NO_OUTPUT:=${NO_OUTPUT:=$_BASHUNIT_DEFAULT_NO_OUTPUT}}" : "${BASHUNIT_INTERNAL_LOG:=${INTERNAL_LOG:=$_BASHUNIT_DEFAULT_INTERNAL_LOG}}" : "${BASHUNIT_SHOW_SKIPPED:=${SHOW_SKIPPED:=$_BASHUNIT_DEFAULT_SHOW_SKIPPED}}" : "${BASHUNIT_SHOW_INCOMPLETE:=${SHOW_INCOMPLETE:=$_BASHUNIT_DEFAULT_SHOW_INCOMPLETE}}" : "${BASHUNIT_STRICT_MODE:=${STRICT_MODE:=$_BASHUNIT_DEFAULT_STRICT_MODE}}" : "${BASHUNIT_STOP_ON_ASSERTION_FAILURE:=${STOP_ON_ASSERTION_FAILURE:=$_BASHUNIT_DEFAULT_STOP_ON_ASSERTION_FAILURE}}" : "${BASHUNIT_SKIP_ENV_FILE:=${SKIP_ENV_FILE:=$_BASHUNIT_DEFAULT_SKIP_ENV_FILE}}" : "${BASHUNIT_LOGIN_SHELL:=${LOGIN_SHELL:=$_BASHUNIT_DEFAULT_LOGIN_SHELL}}" : "${BASHUNIT_FAILURES_ONLY:=${FAILURES_ONLY:=$_BASHUNIT_DEFAULT_FAILURES_ONLY}}" : "${BASHUNIT_SHOW_OUTPUT_ON_FAILURE:=${SHOW_OUTPUT_ON_FAILURE:=$_BASHUNIT_DEFAULT_SHOW_OUTPUT_ON_FAILURE}}" : "${BASHUNIT_NO_DIFF:=${NO_DIFF:=$_BASHUNIT_DEFAULT_NO_DIFF}}" : "${BASHUNIT_NO_PROGRESS:=${NO_PROGRESS:=$_BASHUNIT_DEFAULT_NO_PROGRESS}}" : "${BASHUNIT_OUTPUT_FORMAT:=${OUTPUT_FORMAT:=$_BASHUNIT_DEFAULT_OUTPUT_FORMAT}}" : "${BASHUNIT_FAIL_ON_RISKY:=${FAIL_ON_RISKY:=$_BASHUNIT_DEFAULT_FAIL_ON_RISKY}}" : "${BASHUNIT_PROFILE:=${PROFILE:=$_BASHUNIT_DEFAULT_PROFILE}}" : "${BASHUNIT_PROFILE_COUNT:=${PROFILE_COUNT:=$_BASHUNIT_DEFAULT_PROFILE_COUNT}}" : "${BASHUNIT_TEST_TIMEOUT:=${TEST_TIMEOUT:=$_BASHUNIT_DEFAULT_TEST_TIMEOUT}}" # No bare RETRY alias on purpose: it is too generic and would pick up unrelated # environment values. Only BASHUNIT_RETRY configures retries. : "${BASHUNIT_RETRY:=$_BASHUNIT_DEFAULT_RETRY}" # Single alias on purpose: bare RANDOM_ORDER/SEED are too generic and would pick # up unrelated environment values. : "${BASHUNIT_RANDOM_ORDER:=$_BASHUNIT_DEFAULT_RANDOM_ORDER}" : "${BASHUNIT_SEED:=$_BASHUNIT_DEFAULT_SEED}" : "${BASHUNIT_SHARD_INDEX:=$_BASHUNIT_DEFAULT_SHARD_INDEX}" : "${BASHUNIT_SHARD_TOTAL:=$_BASHUNIT_DEFAULT_SHARD_TOTAL}" # Support NO_COLOR standard (https://no-color.org) if [ -n "${NO_COLOR:-}" ]; then BASHUNIT_NO_COLOR="true" else : "${BASHUNIT_NO_COLOR:=$_BASHUNIT_DEFAULT_NO_COLOR}" fi function bashunit::env::is_parallel_run_enabled() { [ "$BASHUNIT_PARALLEL_RUN" = "true" ] } ## # Whether a per-test timeout is configured (a positive integer number of seconds). # Returns: 0 when enabled, 1 otherwise. ## function bashunit::env::is_test_timeout_enabled() { case "${BASHUNIT_TEST_TIMEOUT:-0}" in '' | *[!0-9]*) return 1 ;; esac [ "${BASHUNIT_TEST_TIMEOUT:-0}" -gt 0 ] } ## # Prints the configured per-test timeout in seconds (0 when disabled). ## function bashunit::env::test_timeout_secs() { printf '%s' "${BASHUNIT_TEST_TIMEOUT:-0}" } ## # Prints the number of extra attempts for a failed test (0 = no retry). # A non-numeric value is treated as 0. ## # Validates BASHUNIT_RETRY into the integer global _BASHUNIT_RETRY_VALIDATED. # In-shell (no fork) so the per-test hot path can read the global instead of # capturing retry_count in a $(...) subshell every test (#764). _BASHUNIT_RETRY_VALIDATED=0 function bashunit::env::resolve_retry_count() { case "${BASHUNIT_RETRY:-0}" in '' | *[!0-9]*) _BASHUNIT_RETRY_VALIDATED=0 ;; *) _BASHUNIT_RETRY_VALIDATED="${BASHUNIT_RETRY:-0}" ;; esac } function bashunit::env::retry_count() { bashunit::env::resolve_retry_count printf '%s' "$_BASHUNIT_RETRY_VALIDATED" } function bashunit::env::is_random_order_enabled() { [ "$BASHUNIT_RANDOM_ORDER" = "true" ] } ## # Prints the configured random-order seed (empty when none set yet). ## function bashunit::env::seed() { printf '%s' "${BASHUNIT_SEED:-}" } function bashunit::env::is_shard_enabled() { [ -n "${BASHUNIT_SHARD_INDEX:-}" ] && [ -n "${BASHUNIT_SHARD_TOTAL:-}" ] } function bashunit::env::shard_index() { printf '%s' "${BASHUNIT_SHARD_INDEX:-}" } function bashunit::env::shard_total() { printf '%s' "${BASHUNIT_SHARD_TOTAL:-}" } function bashunit::env::is_show_header_enabled() { [ "$BASHUNIT_SHOW_HEADER" = "true" ] } function bashunit::env::is_header_ascii_art_enabled() { [ "$BASHUNIT_HEADER_ASCII_ART" = "true" ] } function bashunit::env::is_simple_output_enabled() { [ "$BASHUNIT_SIMPLE_OUTPUT" = "true" ] } function bashunit::env::is_stop_on_failure_enabled() { [ "$BASHUNIT_STOP_ON_FAILURE" = "true" ] } function bashunit::env::is_show_execution_time_enabled() { case "$BASHUNIT_SHOW_EXECUTION_TIME" in true) return 0 ;; auto) ! bashunit::clock::is_expensive ;; *) return 1 ;; esac } # The total "Time taken" footer costs two clock reads per run (negligible), so it # stays visible in "auto" mode even when per-test timing is skipped; only an # explicit "false" hides it (#765). function bashunit::env::is_total_execution_time_enabled() { [ "$BASHUNIT_SHOW_EXECUTION_TIME" != "false" ] } function bashunit::env::is_dev_mode_enabled() { [ -n "$BASHUNIT_DEV_LOG" ] } function bashunit::env::is_internal_log_enabled() { [ "$BASHUNIT_INTERNAL_LOG" = "true" ] } function bashunit::env::is_verbose_enabled() { [ "$BASHUNIT_VERBOSE" = "true" ] } function bashunit::env::is_bench_mode_enabled() { [ "$BASHUNIT_BENCH_MODE" = "true" ] } function bashunit::env::is_no_output_enabled() { [ "$BASHUNIT_NO_OUTPUT" = "true" ] } function bashunit::env::is_show_skipped_enabled() { [ "$BASHUNIT_SHOW_SKIPPED" = "true" ] } function bashunit::env::is_show_incomplete_enabled() { [ "$BASHUNIT_SHOW_INCOMPLETE" = "true" ] } function bashunit::env::is_strict_mode_enabled() { [ "$BASHUNIT_STRICT_MODE" = "true" ] } function bashunit::env::is_stop_on_assertion_failure_enabled() { [ "$BASHUNIT_STOP_ON_ASSERTION_FAILURE" = "true" ] } function bashunit::env::is_skip_env_file_enabled() { [ "$BASHUNIT_SKIP_ENV_FILE" = "true" ] } function bashunit::env::is_login_shell_enabled() { [ "$BASHUNIT_LOGIN_SHELL" = "true" ] } function bashunit::env::is_failures_only_enabled() { [ "$BASHUNIT_FAILURES_ONLY" = "true" ] } function bashunit::env::is_show_output_on_failure_enabled() { [ "$BASHUNIT_SHOW_OUTPUT_ON_FAILURE" = "true" ] } function bashunit::env::is_no_progress_enabled() { [ "$BASHUNIT_NO_PROGRESS" = "true" ] } function bashunit::env::is_no_color_enabled() { [ "$BASHUNIT_NO_COLOR" = "true" ] } function bashunit::env::is_diff_enabled() { # :- guard: a user (or test) may have unset BASHUNIT_NO_DIFF; a bare read # dies under set -u (--strict) (#836) [ "${BASHUNIT_NO_DIFF:-}" != "true" ] } ## # Whether the current terminal can render ANSI color sequences. # Returns 1 when TERM=dumb or when `tput colors` reports fewer than 8. # Returns 0 when tput is missing (assume colors work, preserving prior behavior). ## function bashunit::env::supports_color() { if [ "${TERM:-}" = "dumb" ]; then return 1 fi if ! bashunit::dependencies::has_tput; then return 0 fi local n n=$(tput colors 2>/dev/null) case "$n" in '' | *[!0-9]*) return 0 ;; *) [ "$n" -ge 8 ] ;; esac } function bashunit::env::is_coverage_enabled() { [ "$BASHUNIT_COVERAGE" = "true" ] } function bashunit::env::is_tap_output_enabled() { [ "$BASHUNIT_OUTPUT_FORMAT" = "tap" ] } function bashunit::env::is_fail_on_risky_enabled() { [ "$BASHUNIT_FAIL_ON_RISKY" = "true" ] } function bashunit::env::is_profile_enabled() { [ "$BASHUNIT_PROFILE" = "true" ] } function bashunit::env::active_internet_connection() { if [ "${BASHUNIT_NO_NETWORK:-}" = "true" ]; then return 1 fi if command -v curl >/dev/null 2>&1; then curl -sfI https://github.com >/dev/null 2>&1 && return 0 elif command -v wget >/dev/null 2>&1; then wget -q --spider https://github.com && return 0 fi if ping -c 1 -W 3 google.com &>/dev/null; then return 0 fi return 1 } function bashunit::env::find_terminal_width() { local cols="" if [ -z "$cols" ] && command -v tput >/dev/null; then cols=$(tput cols 2>/dev/null) fi if [ -z "$cols" ] && command -v stty >/dev/null; then cols=$(stty size 2>/dev/null | cut -d' ' -f2) fi # Directly echo the value with fallback echo "${cols:-100}" } function bashunit::env::print_verbose() { bashunit::internal_log "Printing verbose environment variables" local IFS=$' \t\n' # Bash 3.0 compatible: separate declaration and assignment for arrays local keys keys=( "BASHUNIT_DEFAULT_PATH" "BASHUNIT_DEV_LOG" "BASHUNIT_BOOTSTRAP" "BASHUNIT_BOOTSTRAP_ARGS" "BASHUNIT_LOG_JUNIT" "BASHUNIT_LOG_GHA" "BASHUNIT_REPORT_HTML" "BASHUNIT_REPORT_TAP" "BASHUNIT_PARALLEL_RUN" "BASHUNIT_SHOW_HEADER" "BASHUNIT_HEADER_ASCII_ART" "BASHUNIT_SIMPLE_OUTPUT" "BASHUNIT_STOP_ON_FAILURE" "BASHUNIT_SHOW_EXECUTION_TIME" "BASHUNIT_VERBOSE" "BASHUNIT_STRICT_MODE" "BASHUNIT_STOP_ON_ASSERTION_FAILURE" "BASHUNIT_SKIP_ENV_FILE" "BASHUNIT_LOGIN_SHELL" "BASHUNIT_COVERAGE" "BASHUNIT_COVERAGE_PATHS" "BASHUNIT_COVERAGE_EXCLUDE" "BASHUNIT_COVERAGE_REPORT" "BASHUNIT_COVERAGE_REPORT_HTML" "BASHUNIT_COVERAGE_MIN" ) local max_length=0 local key for key in "${keys[@]+"${keys[@]}"}"; do if ((${#key} > max_length)); then max_length=${#key} fi done for key in "${keys[@]+"${keys[@]}"}"; do bashunit::internal_log "$key=${!key}" printf "%s:%*s%s\n" "$key" $((max_length - ${#key} + 1)) "" "${!key}" done } EXIT_CODE_STOP_ON_FAILURE=4 # Use a unique directory per run to avoid conflicts when bashunit is invoked # recursively or multiple instances are executed in parallel. TEMP_DIR_PARALLEL_TEST_SUITE="${TMPDIR:-/tmp}/bashunit/parallel/${_BASHUNIT_OS:-Unknown}/$(bashunit::random_str 8)" TEMP_FILE_PARALLEL_STOP_ON_FAILURE="$TEMP_DIR_PARALLEL_TEST_SUITE/.stop-on-failure" TERMINAL_WIDTH="$(bashunit::env::find_terminal_width)" CAT="$(command -v cat)" GREP="$(command -v grep)" MKTEMP="$(command -v mktemp)" # Deferred-output scratch files. Each used to be its own `mktemp` fork; at ~258 # nested cold starts in the acceptance suite that is ~1.5k forks and dominates # cold-start cost (#798). Derive them from one run-unique directory instead: # `bashunit::random_str` is fork-free and every consumer appends with `>>` (which # creates the file lazily) or guards reads with `[ -s ... ]`, so the files need # not be pre-created. The random suffix keeps the directory unique across # recursive and parallel invocations, matching TEMP_DIR_PARALLEL_TEST_SUITE. _BASHUNIT_RUN_OUTPUT_DIR="${TMPDIR:-/tmp}/bashunit/run/${_BASHUNIT_OS:-Unknown}/$(bashunit::random_str 8)" FAILURES_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/failures" SKIPPED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/skipped" INCOMPLETE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/incomplete" RISKY_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/risky" PROFILE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/profile" # Collects ":" for every failing test in a run so the # next --rerun-failed can replay just those. Shared across parallel subshells. RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed" # Shared temp directory, initialized once at startup for performance. BASHUNIT_TEMP_DIR="${TMPDIR:-/tmp}/bashunit/tmp" # Create both scratch directories in a single `mkdir -p` fork. mkdir -p "$_BASHUNIT_RUN_OUTPUT_DIR" "$BASHUNIT_TEMP_DIR" 2>/dev/null || true # Removes this run's scratch directory (guarded like parallel::cleanup so a # broken variable can never turn the rm loose elsewhere). Called at the end of # a run and on SIGINT; without it every invocation leaks one directory. function bashunit::env::cleanup_run_output_dir() { local target="$_BASHUNIT_RUN_OUTPUT_DIR" case "$target" in */bashunit/run/*) rm -rf "$target" ;; *) bashunit::internal_log "env::cleanup_run_output_dir" "refused unsafe path:$target" return 1 ;; esac } # Cover early-exit paths (--version, --help, doc, init, ...). The test-run path # replaces this trap in main.sh and calls the cleanup explicitly instead; child # subshells never inherit EXIT traps, so a parallel worker cannot remove the # directory mid-run. trap 'bashunit::env::cleanup_run_output_dir' EXIT if bashunit::env::is_dev_mode_enabled; then bashunit::internal_log "info" "Dev log enabled" "file:$BASHUNIT_DEV_LOG" fi # coverage.sh # shellcheck disable=SC2094 # Coverage data storage # Use :- to preserve inherited values from parent bashunit processes _BASHUNIT_COVERAGE_DATA_FILE="${_BASHUNIT_COVERAGE_DATA_FILE:-}" _BASHUNIT_COVERAGE_TRACKED_FILES="${_BASHUNIT_COVERAGE_TRACKED_FILES:-}" # Simple file-based cache for tracked files (Bash 3.0 compatible) # The tracked cache file stores files that have already been processed _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-}" # File to store which tests hit each line (for detailed coverage tooltips) _BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" # In-memory buffer for coverage data (reduces file I/O) _BASHUNIT_COVERAGE_BUFFER="" _BASHUNIT_COVERAGE_BUFFER_COUNT=0 _BASHUNIT_COVERAGE_BUFFER_LIMIT=100 _BASHUNIT_COVERAGE_HITS_BUFFER="" # In-memory caches for hot-path lookups (avoids grep + subshells) _BASHUNIT_COVERAGE_TRACK_CACHE="" _BASHUNIT_COVERAGE_PATH_CACHE="" _BASHUNIT_COVERAGE_IS_PARALLEL="" # Auto-discover coverage paths from test file names # When no explicit coverage paths are set, find source files matching test file base names # Example: tests/unit/assert_test.sh -> finds src/assert.sh, src/assert_*.sh function bashunit::coverage::auto_discover_paths() { local project_root project_root="$(pwd)" local -a discovered_paths=() local discovered_paths_count=0 local test_file for test_file in "$@"; do # Extract base name: tests/unit/assert_test.sh -> assert_test.sh local file_basename file_basename=$(basename "$test_file") # Remove test suffixes to get source name: assert_test.sh -> assert local source_name="${file_basename%_test.sh}" [ "$source_name" = "$file_basename" ] && source_name="${file_basename%Test.sh}" [ "$source_name" = "$file_basename" ] && continue # Not a test file pattern # Find matching source files recursively local found_file while IFS= read -r -d '' found_file; do # Skip test files and vendor directories case "$found_file" in *test* | *Test* | *vendor* | *node_modules*) continue ;; esac discovered_paths[discovered_paths_count]="$found_file" discovered_paths_count=$((discovered_paths_count + 1)) done < <(find "$project_root" -name "${source_name}*.sh" -type f -print0 2>/dev/null) done # Return unique paths, comma-separated if [ "$discovered_paths_count" -gt 0 ]; then printf '%s\n' "${discovered_paths[@]}" | sort -u | tr '\n' ',' | sed 's/,$//' fi } function bashunit::coverage::init() { if ! bashunit::env::is_coverage_enabled; then return 0 fi # Skip coverage init if we're a subprocess of another coverage-enabled bashunit # This prevents nested bashunit calls (e.g., in acceptance tests) from # interfering with the parent's coverage tracking if [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ]; then export BASHUNIT_COVERAGE=false return 0 fi # Create coverage data directory with unique name via mktemp -d # (avoids $$-$RANDOM collisions and symlink races in shared temp dirs) local coverage_dir coverage_dir=$("${MKTEMP:-mktemp}" -d "${BASHUNIT_TEMP_DIR:-${TMPDIR:-/tmp}}/bashunit-coverage.XXXXXXXX") _BASHUNIT_COVERAGE_DATA_FILE="${coverage_dir}/hits.dat" _BASHUNIT_COVERAGE_TRACKED_FILES="${coverage_dir}/files.dat" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${coverage_dir}/cache.dat" _BASHUNIT_COVERAGE_TEST_HITS_FILE="${coverage_dir}/test_hits.dat" # Initialize empty files : >"$_BASHUNIT_COVERAGE_DATA_FILE" : >"$_BASHUNIT_COVERAGE_TRACKED_FILES" : >"$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" : >"$_BASHUNIT_COVERAGE_TEST_HITS_FILE" # Reset in-memory caches and buffers _BASHUNIT_COVERAGE_BUFFER="" _BASHUNIT_COVERAGE_BUFFER_COUNT=0 _BASHUNIT_COVERAGE_HITS_BUFFER="" _BASHUNIT_COVERAGE_TRACK_CACHE="" _BASHUNIT_COVERAGE_PATH_CACHE="" _BASHUNIT_COVERAGE_IS_PARALLEL="" _BASHUNIT_COVERAGE_STATS_FILES=() _BASHUNIT_COVERAGE_STATS_EXEC=() _BASHUNIT_COVERAGE_STATS_HIT=() _BASHUNIT_COVERAGE_STATS_PCT=() _BASHUNIT_COVERAGE_STATS_CLASS=() _BASHUNIT_COVERAGE_STATS_COUNT=0 _BASHUNIT_COVERAGE_STATS_LOOKUP="" export _BASHUNIT_COVERAGE_DATA_FILE export _BASHUNIT_COVERAGE_TRACKED_FILES export _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE export _BASHUNIT_COVERAGE_TEST_HITS_FILE } function bashunit::coverage::enable_trap() { if ! bashunit::env::is_coverage_enabled; then return 0 fi # Enable trap inheritance into functions set -T # Set DEBUG trap to record line execution # Use ${VAR:-} to handle unset variables when set -u is active (in subshells) # shellcheck disable=SC2154 trap 'bashunit::coverage::record_line "${BASH_SOURCE[0]:-}" "${LINENO:-}"' DEBUG } function bashunit::coverage::disable_trap() { trap - DEBUG set +T # Flush any remaining buffered coverage data bashunit::coverage::flush_buffer } # Normalize file path to absolute function bashunit::coverage::normalize_path() { local file="$1" # Normalize path to absolute if [ -f "$file" ]; then echo "$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" else echo "$file" fi } # Get deduplicated list of tracked files function bashunit::coverage::get_tracked_files() { if [ ! -f "$_BASHUNIT_COVERAGE_TRACKED_FILES" ]; then return fi sort -u "$_BASHUNIT_COVERAGE_TRACKED_FILES" } # Get coverage class (high/medium/low) based on percentage function bashunit::coverage::get_coverage_class() { local pct="$1" if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" ]; then echo "high" elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" ]; then echo "medium" else echo "low" fi } function bashunit::coverage::get_color_for_class() { case "$1" in high) printf '%s' "$_BASHUNIT_COLOR_PASSED" ;; medium) printf '%s' "$_BASHUNIT_COLOR_SKIPPED" ;; low) printf '%s' "$_BASHUNIT_COLOR_FAILED" ;; esac } # Calculate percentage from hit and executable counts function bashunit::coverage::calculate_percentage() { local hit="$1" local executable="$2" if [ "$executable" -gt 0 ]; then echo $((hit * 100 / executable)) else echo "0" fi } # Get file coverage stats as "executable:hit:pct:class" function bashunit::coverage::get_file_stats() { local file="$1" local stats executable hit pct class stats=$(bashunit::coverage::compute_file_coverage "$file") executable="${stats%%:*}" hit="${stats##*:}" pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable") class=$(bashunit::coverage::get_coverage_class "$pct") echo "${executable}:${hit}:${pct}:${class}" } # Pre-computed file stats cache (avoids redundant per-file reads across reports) _BASHUNIT_COVERAGE_STATS_FILES=() _BASHUNIT_COVERAGE_STATS_EXEC=() _BASHUNIT_COVERAGE_STATS_HIT=() _BASHUNIT_COVERAGE_STATS_PCT=() _BASHUNIT_COVERAGE_STATS_CLASS=() _BASHUNIT_COVERAGE_STATS_COUNT=0 _BASHUNIT_COVERAGE_STATS_LOOKUP="" # Pre-compute stats for all tracked files (call once before reports) function bashunit::coverage::precompute_file_stats() { _BASHUNIT_COVERAGE_STATS_FILES=() _BASHUNIT_COVERAGE_STATS_EXEC=() _BASHUNIT_COVERAGE_STATS_HIT=() _BASHUNIT_COVERAGE_STATS_PCT=() _BASHUNIT_COVERAGE_STATS_CLASS=() _BASHUNIT_COVERAGE_STATS_COUNT=0 _BASHUNIT_COVERAGE_STATS_LOOKUP="" local file while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue local stats executable hit pct class stats=$(bashunit::coverage::compute_file_coverage "$file") executable="${stats%%:*}" hit="${stats##*:}" pct=$(bashunit::coverage::calculate_percentage "$hit" "$executable") class=$(bashunit::coverage::get_coverage_class "$pct") local idx="$_BASHUNIT_COVERAGE_STATS_COUNT" _BASHUNIT_COVERAGE_STATS_FILES[idx]="$file" _BASHUNIT_COVERAGE_STATS_EXEC[idx]="$executable" _BASHUNIT_COVERAGE_STATS_HIT[idx]="$hit" _BASHUNIT_COVERAGE_STATS_PCT[idx]="$pct" _BASHUNIT_COVERAGE_STATS_CLASS[idx]="$class" _BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1)) _BASHUNIT_COVERAGE_STATS_LOOKUP="${_BASHUNIT_COVERAGE_STATS_LOOKUP}|${file}=${idx}|" done < <(bashunit::coverage::get_tracked_files) } # Look up cached stats for a file, returns "executable:hit:pct:class" function bashunit::coverage::get_cached_stats() { local file="$1" case "$_BASHUNIT_COVERAGE_STATS_LOOKUP" in *"|${file}="*) local idx="${_BASHUNIT_COVERAGE_STATS_LOOKUP#*"|${file}="}" idx="${idx%%"|"*}" echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}:${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}" return 0 ;; esac bashunit::coverage::get_file_stats "$file" } function bashunit::coverage::record_line() { local file="$1" local lineno="$2" # Skip if no file or line { [ -z "$file" ] || [ -z "$lineno" ]; } && return 0 # Skip if coverage data file doesn't exist (trap inherited by child process) [ -z "$_BASHUNIT_COVERAGE_DATA_FILE" ] && return 0 # Fast in-memory should_track cache (avoids grep + file I/O per line) case "$_BASHUNIT_COVERAGE_TRACK_CACHE" in *"|${file}:0|"*) return 0 ;; *"|${file}:1|"*) ;; *) # Not cached yet — run full check and cache result if bashunit::coverage::should_track "$file"; then _BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:1|" else _BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:0|" return 0 fi ;; esac # Fast in-memory path normalization cache (avoids cd + pwd subshell per line) local normalized_file="" case "$_BASHUNIT_COVERAGE_PATH_CACHE" in *"|${file}="*) # Extract cached value normalized_file="${_BASHUNIT_COVERAGE_PATH_CACHE#*"|${file}="}" normalized_file="${normalized_file%%"|"*}" ;; *) normalized_file=$(bashunit::coverage::normalize_path "$file") _BASHUNIT_COVERAGE_PATH_CACHE="${_BASHUNIT_COVERAGE_PATH_CACHE}|${file}=${normalized_file}|" ;; esac # Buffer the coverage data in memory _BASHUNIT_COVERAGE_BUFFER="${_BASHUNIT_COVERAGE_BUFFER}${normalized_file}:${lineno} " # Also buffer test hit data if in a test context if [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" ] && [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]; then _BASHUNIT_COVERAGE_HITS_BUFFER="${_BASHUNIT_COVERAGE_HITS_BUFFER}${normalized_file}:${lineno}|${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN} " fi _BASHUNIT_COVERAGE_BUFFER_COUNT=$((_BASHUNIT_COVERAGE_BUFFER_COUNT + 1)) # Flush buffer to disk when threshold is reached if [ "$_BASHUNIT_COVERAGE_BUFFER_COUNT" -ge \ "$_BASHUNIT_COVERAGE_BUFFER_LIMIT" ]; then bashunit::coverage::flush_buffer fi } function bashunit::coverage::flush_buffer() { [ -z "$_BASHUNIT_COVERAGE_BUFFER" ] && return 0 # Determine output files (parallel-safe) local data_file="$_BASHUNIT_COVERAGE_DATA_FILE" local test_hits_file="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" # Cache the parallel check to avoid function calls if [ -z "$_BASHUNIT_COVERAGE_IS_PARALLEL" ]; then if bashunit::parallel::is_enabled; then _BASHUNIT_COVERAGE_IS_PARALLEL="yes" else _BASHUNIT_COVERAGE_IS_PARALLEL="no" fi fi if [ "$_BASHUNIT_COVERAGE_IS_PARALLEL" = "yes" ]; then data_file="${_BASHUNIT_COVERAGE_DATA_FILE}.$$" test_hits_file="${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.$$" fi # Write buffered data in a single I/O operation. # Use `builtin printf` so a user test spying/mocking the printf builtin # cannot shadow the coverage write and silently drop data (see issue #724). builtin printf '%s' "$_BASHUNIT_COVERAGE_BUFFER" >>"$data_file" if [ -n "$_BASHUNIT_COVERAGE_HITS_BUFFER" ]; then builtin printf '%s' "$_BASHUNIT_COVERAGE_HITS_BUFFER" >>"$test_hits_file" fi # Reset buffer _BASHUNIT_COVERAGE_BUFFER="" _BASHUNIT_COVERAGE_HITS_BUFFER="" _BASHUNIT_COVERAGE_BUFFER_COUNT=0 } function bashunit::coverage::should_track() { local file="$1" # Skip empty paths [ -z "$file" ] && return 1 # Skip if tracked files list doesn't exist (trap inherited by child process) [ -z "$_BASHUNIT_COVERAGE_TRACKED_FILES" ] && return 1 # Check file-based cache for previous decision (Bash 3.0 compatible) # Cache format: "file:0" for excluded, "file:1" for tracked # In parallel mode, use per-process cache to avoid race conditions local cache_file="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" if bashunit::parallel::is_enabled && [ -n "$cache_file" ]; then cache_file="${cache_file}.$$" # Initialize per-process cache if needed [ ! -f "$cache_file" ] && [ -d "$(dirname "$cache_file")" ] && : >"$cache_file" fi if [ -n "$cache_file" ] && [ -f "$cache_file" ]; then local cached_decision # Use || true to prevent exit in strict mode when grep finds no match cached_decision=$(grep "^${file}:" "$cache_file" 2>/dev/null | head -1) || true if [ -n "$cached_decision" ]; then [ "${cached_decision##*:}" = "1" ] && return 0 || return 1 fi fi # Normalize path local normalized_file normalized_file=$(bashunit::coverage::normalize_path "$file") # Check exclusion patterns # Save and restore IFS to avoid corrupting caller's environment local old_ifs="$IFS" IFS=',' local pattern for pattern in $BASHUNIT_COVERAGE_EXCLUDE; do # shellcheck disable=SC2254 case "$normalized_file" in *$pattern*) IFS="$old_ifs" # Cache exclusion decision (use per-process cache in parallel mode) { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } && echo "${file}:0" >>"$cache_file" return 1 ;; esac done # Check inclusion paths local matched=false local path for path in $BASHUNIT_COVERAGE_PATHS; do # Resolve relative paths local resolved_path case "$path" in /*) resolved_path="$path" ;; *) resolved_path="$(pwd)/$path" ;; esac case "$normalized_file" in "$resolved_path"*) matched=true break ;; esac done IFS="$old_ifs" if [ "$matched" = "false" ]; then # Cache exclusion decision (use per-process cache in parallel mode) { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } && echo "${file}:0" >>"$cache_file" return 1 fi # Cache tracking decision (use per-process cache in parallel mode) { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } && echo "${file}:1" >>"$cache_file" # Track this file for later reporting # In parallel mode, use a per-process file to avoid race conditions local tracked_file="$_BASHUNIT_COVERAGE_TRACKED_FILES" if bashunit::parallel::is_enabled; then tracked_file="${_BASHUNIT_COVERAGE_TRACKED_FILES}.$$" fi # Only write if parent directory exists if [ -d "$(dirname "$tracked_file")" ]; then # Check if not already written to avoid duplicates if ! grep -q "^${normalized_file}$" "$tracked_file" 2>/dev/null; then echo "$normalized_file" >>"$tracked_file" fi fi return 0 } function bashunit::coverage::aggregate_parallel() { # Aggregate per-process coverage files created during parallel execution local base_file="$_BASHUNIT_COVERAGE_DATA_FILE" local tracked_base="$_BASHUNIT_COVERAGE_TRACKED_FILES" local test_hits_base="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" # Find and merge all per-process coverage data files # Use nullglob to handle case when no files match local pid_files pid_file pid_files=$(ls -1 "${base_file}."* 2>/dev/null) || true if [ -n "$pid_files" ]; then while IFS= read -r pid_file; do [ -f "$pid_file" ] || continue cat "$pid_file" >>"$base_file" rm -f "$pid_file" done <<<"$pid_files" fi # Find and merge all per-process tracked files lists pid_files=$(ls -1 "${tracked_base}."* 2>/dev/null) || true if [ -n "$pid_files" ]; then while IFS= read -r pid_file; do [ -f "$pid_file" ] || continue cat "$pid_file" >>"$tracked_base" rm -f "$pid_file" done <<<"$pid_files" fi # Find and merge all per-process test hits files if [ -n "$test_hits_base" ]; then pid_files=$(ls -1 "${test_hits_base}."* 2>/dev/null) || true if [ -n "$pid_files" ]; then while IFS= read -r pid_file; do [ -f "$pid_file" ] || continue cat "$pid_file" >>"$test_hits_base" rm -f "$pid_file" done <<<"$pid_files" fi fi # Deduplicate tracked files if [ -f "$tracked_base" ]; then sort -u "$tracked_base" -o "$tracked_base" fi } # Pre-compiled combined regex of all non-executable line patterns. # Collapses multiple grep subshells into a single invocation per line for performance. # Each alternation is fully self-anchored so semantics match the original per-pattern checks. # Patterns covered (in order): # - comment-only lines (including shebang) # - function declarations (but not single-line functions with a body) # - brace-only lines # - control flow keywords (then, else, fi, do, done, esac, in, ;;, ;;&, ;&) # - loop terminators with redirection/pipe/fd (e.g. "done < file", "done | sort") # - case patterns like "--option)" or "*) # comment" # - standalone ) for arrays/subshells _BASHUNIT_COVERAGE_NONEXEC_PATTERN='^[[:space:]]*#' _BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*(function[[:space:]]+)?[a-zA-Z_][a-zA-Z0-9_:]*[[:space:]]*\(\)[[:space:]]*\{?[[:space:]]*$' _BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*[\{\}][[:space:]]*$' _BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*(then|else|fi|do|done|esac|in|;;|;;&|;&)[[:space:]]*(#.*)?$' _BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*done[[:space:]]+[^[:space:]#].*$' _BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*[^\)]+\)[[:space:]]*(#.*)?$' _BASHUNIT_COVERAGE_NONEXEC_PATTERN="${_BASHUNIT_COVERAGE_NONEXEC_PATTERN}"'|^[[:space:]]*\)[[:space:]]*(#.*)?$' # Check if a line is executable (used by get_executable_lines and report_lcov) # Arguments: line content, line number # Returns: 0 if executable, 1 if not function bashunit::coverage::is_executable_line() { local line="$1" local lineno="$2" # Unused but kept for API compatibility : "$lineno" # Skip empty lines (line with only whitespace) — built-in, no subshell [ -z "${line// /}" ] && return 1 # Fast path: pure Bash checks for common non-executable patterns (no subshell) local stripped="${line#"${line%%[![:space:]]*}"}" local _trail="${stripped##*[![:space:]]}" local trimmed="${stripped%"$_trail"}" case "$trimmed" in '#'*) return 1 ;; # Comments (including shebang) '{' | '}') return 1 ;; # Braces only esac local first="${trimmed%%[[:space:]]*}" case "$first" in 'then' | 'else' | 'fi' | 'do' | 'done' | 'esac' | 'in' | ';;' | ';;&' | ';&' | ')') local rest="${trimmed#"$first"}" local _rl="${rest%%[![:space:]]*}" rest="${rest#"$_rl"}" case "$rest" in '' | '#'*) return 1 ;; esac ;; esac # Fallback: grep for complex patterns (function declarations, case patterns, done+redirection) [ "$(printf '%s' "$line" | "$GREP" -cE "$_BASHUNIT_COVERAGE_NONEXEC_PATTERN" || true)" -gt 0 ] && return 1 return 0 } function bashunit::coverage::get_executable_lines() { local file="$1" local count=0 local lineno=0 local line while IFS= read -r line || [ -n "$line" ]; do ((++lineno)) bashunit::coverage::is_executable_line "$line" "$lineno" && ((++count)) done <"$file" echo "$count" } function bashunit::coverage::get_hit_lines() { local file="$1" if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then echo "0" return fi # Get unique hit line numbers local hit_lines hit_lines=$( (grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null || true) | cut -d: -f2 | sort -u) if [ -z "$hit_lines" ]; then echo "0" return fi # Only count hits that correspond to executable lines # This prevents >100% coverage when DEBUG trap fires on non-executable lines # Pre-load file lines into indexed array (avoids sed per line) local -a file_lines=() local _idx=0 _fl while IFS= read -r _fl || [ -n "$_fl" ]; do file_lines[_idx]="$_fl" ((++_idx)) done <"$file" local count=0 local line_num for line_num in $hit_lines; do local line_content="${file_lines[$((line_num - 1))]:-}" [ -z "$line_content" ] && continue if bashunit::coverage::is_executable_line "$line_content" "$line_num"; then ((++count)) fi done echo "$count" } function bashunit::coverage::get_line_hits() { local file="$1" local lineno="$2" if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then echo "0" return fi local count count=$("$GREP" -c "^${file}:${lineno}$" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null) || count=0 echo "$count" } # Compute executable + hit counts for a file in a single source-file pass. # Reuses get_all_line_hits to avoid scanning the coverage data per line. # Output format: "executable:hit" function bashunit::coverage::compute_file_coverage() { local file="$1" local -a hits_by_line=() local hit_lineno hit_count while IFS=: read -r hit_lineno hit_count; do [ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count done < <(bashunit::coverage::get_all_line_hits "$file") local executable=0 hit=0 lineno=0 line line_hits local -a cv_lines=() local _cli=0 _cl while IFS= read -r _cl || [ -n "$_cl" ]; do cv_lines[_cli]="$_cl" ((++_cli)) done <"$file" for line in "${cv_lines[@]}"; do ((++lineno)) bashunit::coverage::is_executable_line "$line" "$lineno" || continue ((++executable)) line_hits=${hits_by_line[lineno]:-0} [ "$line_hits" -gt 0 ] && ((++hit)) done echo "${executable}:${hit}" } # Detect whether a source line ends with a Bash line-continuation, i.e. an # odd number of unescaped trailing backslashes with no trailing whitespace. # Comment lines never continue. Used to propagate coverage hits from a # statement's starting line to its continuation lines (see #722). function bashunit::coverage::_ends_with_continuation() { local line="$1" local lead="${line#"${line%%[![:space:]]*}"}" case "$lead" in '#'*) return 1 ;; esac local trailing="${line##*[!\\]}" case "$line" in *[!\\]*) : ;; *) trailing="$line" ;; esac [ $((${#trailing} % 2)) -eq 1 ] } # Get all line hits for a file in one pass (performance optimization) # Output format: one "lineno:count" per line # # Bash's DEBUG trap attributes a multi-line statement's execution to the line # where the statement starts; backslash continuation lines never receive their # own hit. To match the report's expectation that continuation lines are # covered, the start line's count is propagated forward across the # continuation chain (see #722). function bashunit::coverage::get_all_line_hits() { local file="$1" if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then return fi # Extract all lines for this file, count occurrences of each line number. local -a counts=() local count lineno maxln=0 while read -r count lineno; do if [ -n "$lineno" ]; then counts[lineno]=$count [ "$lineno" -gt "$maxln" ] && maxln=$lineno fi done < <(grep "^${file}:" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null | cut -d: -f2 | sort | uniq -c) if [ "$maxln" -eq 0 ]; then return fi # Read the source so continuation lines can be detected. local -a src=() local _i=0 _l while IFS= read -r _l || [ -n "$_l" ]; do src[_i]="$_l" ((++_i)) done <"$file" local total=$_i [ "$maxln" -gt "$total" ] && total=$maxln # Propagate each start line's count forward across its continuation chain. local carry=0 idx h for ((idx = 1; idx <= total; idx++)); do h=${counts[idx]:-0} if [ "$carry" -gt 0 ] && [ "$h" -lt "$carry" ]; then h=$carry counts[idx]=$h fi if [ "$h" -gt 0 ] && bashunit::coverage::_ends_with_continuation "${src[idx - 1]:-}"; then carry=$h else carry=0 fi done local ln for ((ln = 1; ln <= total; ln++)); do [ "${counts[ln]:-0}" -gt 0 ] && echo "${ln}:${counts[ln]}" done # The for loop's exit status leaks the last `[ -gt ]` test, which is 1 when the # final line has no hits; return 0 explicitly so callers under `set -e` (strict # mode) don't treat a successful run as a failure (see #722). return 0 } # Get all test hits for a file in one pass (performance optimization) # Output format: lineno|test_file:test_function (may have duplicates, one per hit) function bashunit::coverage::get_all_line_tests() { local file="$1" if [ ! -f "${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" ]; then return fi # Format in file: source_file:line|test_file:test_function # Output: lineno|test_file:test_function grep "^${file}:" "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 2>/dev/null | sed "s|^${file}:||" | sort -u } # Extract function definitions from a bash file # Output format: function_name:start_line:end_line (one per function) function bashunit::coverage::extract_functions() { local file="$1" local lineno=0 local in_function=0 local brace_count=0 local current_fn="" local fn_start=0 local line while IFS= read -r line || [ -n "$line" ]; do ((++lineno)) # Check for function definition patterns # Pattern 1: function name() { or function name { # Pattern 2: name() { or name () { if [ "$in_function" -eq 0 ]; then local fn_name="" # Extract function name using pure Bash string operations (avoids sed subshell) local stripped="${line#"${line%%[![:space:]]*}"}" # Strip "function " prefix if present case "$stripped" in function[\ \ ]*) stripped="${stripped#function}" stripped="${stripped#"${stripped%%[![:space:]]*}"}" ;; esac # Extract first word as candidate function name fn_name="${stripped%%[[:space:]\(\{]*}" # Validate: must start with valid identifier char, and rest must have () or { if [ -n "$fn_name" ]; then case "$fn_name" in [a-zA-Z_]*) local after_name="${stripped#"$fn_name"}" after_name="${after_name#"${after_name%%[![:space:]]*}"}" case "$after_name" in '()'* | '{'*) ;; *) fn_name="" ;; esac ;; *) fn_name="" ;; esac fi if [ -n "$fn_name" ]; then in_function=1 current_fn="$fn_name" fn_start=$lineno brace_count=0 # Count opening braces on this line local open_braces="${line//[^\{]/}" local close_braces="${line//[^\}]/}" local open_count=${#open_braces} local close_count=${#close_braces} brace_count=$((brace_count + open_count - close_count)) # Single-line function: braces balance on same line and both present if [ "$brace_count" -eq 0 ] && [ "$open_count" -gt 0 ] && [ "$close_count" -gt 0 ]; then echo "${current_fn}|${fn_start}|${lineno}" in_function=0 current_fn="" fi continue fi fi # Track braces inside function if [ "$in_function" -eq 1 ]; then local open_braces="${line//[^\{]/}" local close_braces="${line//[^\}]/}" brace_count=$((brace_count + ${#open_braces} - ${#close_braces})) # Function ended if [ "$brace_count" -le 0 ]; then echo "${current_fn}|${fn_start}|${lineno}" in_function=0 current_fn="" brace_count=0 fi fi done <"$file" # Handle unclosed function (shouldn't happen in valid code) if [ "$in_function" -eq 1 ] && [ -n "$current_fn" ]; then echo "${current_fn}|${fn_start}|${lineno}" fi } # Append "start:end" to a comma-separated arms string. Result is # returned via the global _BASHUNIT_BRANCH_ARMS_OUT to avoid the cost # of a subshell on a hot per-line path. Bash 3.0 cannot pass arrays # (or namerefs) by reference, so a single output slot is the cheapest # portable option. _BASHUNIT_BRANCH_ARMS_OUT="" function bashunit::coverage::_append_arm() { local existing="$1" arm_start="$2" arm_end="$3" if [ -z "$existing" ]; then _BASHUNIT_BRANCH_ARMS_OUT="${arm_start}:${arm_end}" else _BASHUNIT_BRANCH_ARMS_OUT="${existing},${arm_start}:${arm_end}" fi } # Detect whether a trimmed line is a case-pattern opener (ends with # `)` optionally followed by whitespace and a comment). Avoids # matching mid-line uses such as `cmd $(other)`. function bashunit::coverage::_is_case_pattern_line() { local trimmed="$1" case "$trimmed" in *')'*) ;; *) return 1 ;; esac local before_paren="${trimmed%%')'*}" local after="${trimmed#"$before_paren"}" after="${after#)}" after="${after#"${after%%[![:space:]]*}"}" case "$after" in '' | '#'*) return 0 ;; esac return 1 } # Extract branch points from a Bash file. # Output format: ||:[,:]... # kind ∈ {if, case} # Scope: if/elif/else chains and case patterns. See adrs/adr-007-branch-coverage-mvp.md. # The handlers below operate on the per-construct state arrays that # extract_branches keeps as locals. Bash 3.0 has dynamic scoping for # `local` vars, so the helpers see and mutate the caller's state # without needing namerefs (which would require Bash 4.3+). function bashunit::coverage::_branch_push_if() { local lineno=$1 if_decision_line[if_depth]=$lineno if_arms[if_depth]="" if_arm_start[if_depth]=$((lineno + 1)) if_depth=$((if_depth + 1)) } function bashunit::coverage::_branch_close_if_arm() { local lineno=$1 idx=$((if_depth - 1)) bashunit::coverage::_append_arm \ "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" if_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" if_arm_start[idx]=$((lineno + 1)) } function bashunit::coverage::_branch_emit_if() { local lineno=$1 idx=$((if_depth - 1)) bashunit::coverage::_append_arm \ "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" echo "${if_decision_line[$idx]}|if|${_BASHUNIT_BRANCH_ARMS_OUT}" if_depth=$idx } function bashunit::coverage::_branch_push_case() { local lineno=$1 case_decision_line[case_depth]=$lineno case_arms[case_depth]="" case_arm_start[case_depth]=0 case_in_pattern[case_depth]=0 case_depth=$((case_depth + 1)) } function bashunit::coverage::_branch_close_case_arm() { local lineno=$1 idx=$((case_depth - 1)) [ "${case_in_pattern[$idx]}" = "1" ] || return 0 bashunit::coverage::_append_arm \ "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$((lineno - 1))" case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" case_in_pattern[idx]=0 } function bashunit::coverage::_branch_emit_case() { local lineno=$1 idx=$((case_depth - 1)) bashunit::coverage::_branch_close_case_arm "$lineno" if [ -n "${case_arms[$idx]}" ]; then echo "${case_decision_line[$idx]}|case|${case_arms[$idx]}" fi case_depth=$idx } function bashunit::coverage::_branch_open_case_pattern() { local lineno=$1 idx=$((case_depth - 1)) case_arm_start[idx]=$((lineno + 1)) case_in_pattern[idx]=1 } function bashunit::coverage::extract_branches() { local file="$1" local -a lines=() local _i=0 _l while IFS= read -r _l || [ -n "$_l" ]; do lines[_i]="$_l" ((++_i)) done <"$file" local total_lines=$_i # State arrays — read and mutated by the _branch_* helpers via Bash's # dynamic scoping. Each array is keyed by depth so nested constructs # work without associative arrays. local -a if_decision_line=() if_arms=() if_arm_start=() local if_depth=0 local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=() local case_depth=0 local lineno=0 line trimmed first while [ "$lineno" -lt "$total_lines" ]; do line="${lines[$lineno]}" lineno=$((lineno + 1)) trimmed="${line#"${line%%[![:space:]]*}"}" case "$trimmed" in '' | '#'*) continue ;; esac first="${trimmed%%[[:space:]\;]*}" # Reserved-word patterns single-quoted to dodge `case ... esac` # parser confusion. case "$first" in 'if') bashunit::coverage::_branch_push_if "$lineno" ;; 'elif' | 'else') [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_close_if_arm "$lineno" ;; 'fi') [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_emit_if "$lineno" ;; 'case') bashunit::coverage::_branch_push_case "$lineno" ;; 'esac') [ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno" ;; *) [ "$case_depth" -eq 0 ] && continue case "$trimmed" in ';;&'* | ';;'* | ';&'*) bashunit::coverage::_branch_close_case_arm "$lineno" ;; *) if bashunit::coverage::_is_case_pattern_line "$trimmed"; then bashunit::coverage::_branch_open_case_pattern "$lineno" fi ;; esac ;; esac done } # Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in # [arm_start..arm_end] has a recorded hit, else 0. Caller must have # populated the hits_by_line and src_lines arrays in scope; Bash 3.0 # cannot pass arrays into a function. Result is returned via the # global to avoid a per-arm subshell. _BASHUNIT_ARM_TAKEN_OUT=0 function bashunit::coverage::_arm_taken() { local arm_start="$1" arm_end="$2" ln for ((ln = arm_start; ln <= arm_end; ln++)); do bashunit::coverage::is_executable_line \ "${src_lines[$((ln - 1))]:-}" "$ln" || continue if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then _BASHUNIT_ARM_TAKEN_OUT=1 return fi done _BASHUNIT_ARM_TAKEN_OUT=0 } # Compute branch hit data for a file. # Output format: ||| # block = sequential id per decision (0..N-1), branch_index = arm index (0..M-1). # An arm is "taken" iff at least one executable line inside its range # has a recorded hit. taken_count is 0 or 1 — MVP does not preserve # per-arm hit counts. function bashunit::coverage::compute_branch_hits() { local file="$1" local -a hits_by_line=() local _hl_ln _hl_cnt while IFS=: read -r _hl_ln _hl_cnt; do [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt done < <(bashunit::coverage::get_all_line_hits "$file") local -a src_lines=() local _sli=0 _sl while IFS= read -r _sl || [ -n "$_sl" ]; do src_lines[_sli]="$_sl" ((++_sli)) done <"$file" local block=0 decision_line _kind arms branch_entry local -a arm_specs=() local arm arm_index while IFS= read -r branch_entry; do [ -z "$branch_entry" ] && continue IFS='|' read -r decision_line _kind arms <<<"$branch_entry" arm_index=0 IFS=',' read -ra arm_specs <<<"$arms" for arm in "${arm_specs[@]}"; do bashunit::coverage::_arm_taken "${arm%%:*}" "${arm##*:}" echo "${decision_line}|${block}|${arm_index}|${_BASHUNIT_ARM_TAKEN_OUT}" arm_index=$((arm_index + 1)) done block=$((block + 1)) done < <(bashunit::coverage::extract_branches "$file") } function bashunit::coverage::get_percentage() { local total_executable=0 local total_hit=0 if [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ]; then local i for ((i = 0; i < _BASHUNIT_COVERAGE_STATS_COUNT; i++)); do total_executable=$((total_executable + _BASHUNIT_COVERAGE_STATS_EXEC[i])) total_hit=$((total_hit + _BASHUNIT_COVERAGE_STATS_HIT[i])) done else while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue local executable hit executable=$(bashunit::coverage::get_executable_lines "$file") hit=$(bashunit::coverage::get_hit_lines "$file") total_executable=$((total_executable + executable)) total_hit=$((total_hit + hit)) done < <(bashunit::coverage::get_tracked_files) fi bashunit::coverage::calculate_percentage "$total_hit" "$total_executable" } function bashunit::coverage::report_text() { if ! bashunit::env::is_coverage_enabled; then return 0 fi local total_executable=0 local total_hit=0 local has_files=false echo "" echo "Coverage Report" echo "---------------" local file while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue has_files=true local executable hit pct class stats rest stats=$(bashunit::coverage::get_cached_stats "$file") executable="${stats%%:*}" rest="${stats#*:}" hit="${rest%%:*}" rest="${rest#*:}" pct="${rest%%:*}" class="${rest#*:}" total_executable=$((total_executable + executable)) total_hit=$((total_hit + hit)) local color reset="$_BASHUNIT_COLOR_DEFAULT" color=$(bashunit::coverage::get_color_for_class "$class") # Display relative path local display_file="${file#"$(pwd)"/}" printf "%s%-40s %3d/%3d lines (%3d%%)%s\n" \ "$color" "$display_file" "$hit" "$executable" "$pct" "$reset" done < <(bashunit::coverage::get_tracked_files) if [ "$has_files" != "true" ]; then echo "---------------" echo "Total: 0/0 (0%)" return 0 fi echo "---------------" # Total local total_pct total_class total_pct=$(bashunit::coverage::calculate_percentage "$total_hit" "$total_executable") total_class=$(bashunit::coverage::get_coverage_class "$total_pct") local color reset="$_BASHUNIT_COLOR_DEFAULT" color=$(bashunit::coverage::get_color_for_class "$total_class") printf "%sTotal: %d/%d (%d%%)%s\n" \ "$color" "$total_hit" "$total_executable" "$total_pct" "$reset" # Optional per-function summary (gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS) if [ "${BASHUNIT_COVERAGE_SHOW_FUNCTIONS:-false}" = "true" ]; then bashunit::coverage::report_text_functions fi # Optional uncovered hotspots (gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED) if [ "${BASHUNIT_COVERAGE_SHOW_UNCOVERED:-false}" = "true" ]; then bashunit::coverage::report_text_uncovered fi # Show report location if generated if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then echo "" echo "Coverage report written to: $BASHUNIT_COVERAGE_REPORT" fi } # Compress a sorted list of integers into a comma-separated range # string (e.g. "3 4 5 7 9 10" -> "3-5,7,9-10"). Result on # _BASHUNIT_RANGES_OUT to avoid a subshell on each call. _BASHUNIT_RANGES_OUT="" function bashunit::coverage::_compress_ranges() { local out="" start="" end="" n for n in "$@"; do if [ -z "$start" ]; then start="$n" end="$n" elif [ "$n" -eq $((end + 1)) ]; then end="$n" else if [ "$start" = "$end" ]; then out="${out}${start}," else out="${out}${start}-${end}," fi start="$n" end="$n" fi done if [ -n "$start" ]; then if [ "$start" = "$end" ]; then out="${out}${start}" else out="${out}${start}-${end}" fi fi _BASHUNIT_RANGES_OUT="${out%,}" } # List executable lines that were never hit, grouped by file. # Gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED=true. Output is suppressed # when no uncovered lines exist so a fully-covered run stays quiet. function bashunit::coverage::report_text_uncovered() { local file local printed_header=false while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue local -a hits_by_line=() local _hl_ln _hl_cnt while IFS=: read -r _hl_ln _hl_cnt; do [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt done < <(bashunit::coverage::get_all_line_hits "$file") local -a uncovered_lines=() local _ucount=0 local lineno=0 line while IFS= read -r line || [ -n "$line" ]; do lineno=$((lineno + 1)) bashunit::coverage::is_executable_line "$line" "$lineno" || continue local lh="${hits_by_line[$lineno]:-0}" if [ "$lh" -eq 0 ]; then uncovered_lines[_ucount]="$lineno" _ucount=$((_ucount + 1)) fi done <"$file" [ "$_ucount" -eq 0 ] && continue if [ "$printed_header" != "true" ]; then echo "" echo "Uncovered Lines" echo "---------------" printed_header=true fi local display_file="${file#"$(pwd)"/}" local color="$_BASHUNIT_COLOR_FAILED" reset="$_BASHUNIT_COLOR_DEFAULT" local out bashunit::coverage::_compress_ranges "${uncovered_lines[@]}" out="$_BASHUNIT_RANGES_OUT" printf "%s%s:%s%s\n" "$color" "$display_file" "$out" "$reset" done < <(bashunit::coverage::get_tracked_files) } # Per-function coverage summary printed after the file table. # Gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true to keep default output compact. function bashunit::coverage::report_text_functions() { local file local printed_header=false while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue local functions_data functions_data=$(bashunit::coverage::extract_functions "$file") [ -z "$functions_data" ] && continue local -a hits_by_line=() local _hl_ln _hl_cnt while IFS=: read -r _hl_ln _hl_cnt; do [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt done < <(bashunit::coverage::get_all_line_hits "$file") local -a file_lines=() local _fli=0 _fl while IFS= read -r _fl || [ -n "$_fl" ]; do file_lines[_fli]="$_fl" ((++_fli)) done <"$file" local display_file="${file#"$(pwd)"/}" if [ "$printed_header" != "true" ]; then echo "" echo "Functions" echo "---------" printed_header=true fi echo "${display_file}" local fn_name fn_start fn_end ln fn_executable fn_hit local fn_pct fn_class color reset="$_BASHUNIT_COLOR_DEFAULT" while IFS='|' read -r fn_name fn_start fn_end; do [ -z "$fn_name" ] && continue fn_executable=0 fn_hit=0 for ((ln = fn_start; ln <= fn_end; ln++)); do bashunit::coverage::is_executable_line \ "${file_lines[$((ln - 1))]:-}" "$ln" || continue fn_executable=$((fn_executable + 1)) [ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1)) done fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable") fn_class=$(bashunit::coverage::get_coverage_class "$fn_pct") color=$(bashunit::coverage::get_color_for_class "$fn_class") printf " %s%-38s %3d/%3d lines (%3d%%)%s\n" \ "$color" "$fn_name" "$fn_hit" "$fn_executable" "$fn_pct" "$reset" done <<<"$functions_data" done < <(bashunit::coverage::get_tracked_files) } function bashunit::coverage::report_lcov() { local output_file="${1:-$BASHUNIT_COVERAGE_REPORT}" if [ -z "$output_file" ]; then return 0 fi # Create output directory if needed mkdir -p "$(dirname "$output_file")" # Generate LCOV format { echo "TN:" while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue echo "SF:$file" local -a hits_by_line=() local hit_lineno hit_count while IFS=: read -r hit_lineno hit_count; do [ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count done < <(bashunit::coverage::get_all_line_hits "$file") # Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk # and buffer the matching FNDA lines for emission after, per # LCOV convention. local fn_total=0 fn_hit=0 fn_name fn_start fn_end fln any_hit local -a fn_dn_records=() local _fdi=0 while IFS='|' read -r fn_name fn_start fn_end; do [ -z "$fn_name" ] && continue echo "FN:${fn_start},${fn_name}" fn_total=$((fn_total + 1)) any_hit=0 for ((fln = fn_start; fln <= fn_end; fln++)); do if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then any_hit=1 break fi done fn_dn_records[_fdi]="FNDA:${any_hit},${fn_name}" _fdi=$((_fdi + 1)) [ "$any_hit" -eq 1 ] && fn_hit=$((fn_hit + 1)) done < <(bashunit::coverage::extract_functions "$file") local fda for fda in ${fn_dn_records[@]+"${fn_dn_records[@]}"}; do echo "$fda" done echo "FNF:$fn_total" echo "FNH:$fn_hit" # Branch records (BRDA/BRF/BRH) local br_total=0 br_hit=0 br_line br_block br_idx br_taken while IFS='|' read -r br_line br_block br_idx br_taken; do [ -z "$br_line" ] && continue echo "BRDA:${br_line},${br_block},${br_idx},${br_taken}" br_total=$((br_total + 1)) [ "$br_taken" -gt 0 ] && br_hit=$((br_hit + 1)) done < <(bashunit::coverage::compute_branch_hits "$file") echo "BRF:$br_total" echo "BRH:$br_hit" local lineno=0 executable=0 hit=0 line line_hits local -a lcov_lines=() local _lli=0 _ll while IFS= read -r _ll || [ -n "$_ll" ]; do lcov_lines[_lli]="$_ll" ((++_lli)) done <"$file" for line in "${lcov_lines[@]}"; do ((++lineno)) bashunit::coverage::is_executable_line "$line" "$lineno" || continue ((++executable)) local lh="${hits_by_line[$lineno]:-0}" [ "$lh" -gt 0 ] && ((++hit)) echo "DA:${lineno},${lh}" done echo "LF:$executable" echo "LH:$hit" echo "end_of_record" done < <(bashunit::coverage::get_tracked_files) } >"$output_file" } function bashunit::coverage::check_threshold() { if [ -z "$BASHUNIT_COVERAGE_MIN" ]; then return 0 fi local pct pct=$(bashunit::coverage::get_percentage) if [ "$pct" -lt "$BASHUNIT_COVERAGE_MIN" ]; then printf "%sCoverage %d%% is below minimum %d%%%s\n" \ "$_BASHUNIT_COLOR_FAILED" "$pct" "$BASHUNIT_COVERAGE_MIN" "$_BASHUNIT_COLOR_DEFAULT" return 1 fi return 0 } # Escape HTML special characters # Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings) function bashunit::coverage::html_escape() { local text="$1" printf "%s" "$text" | sed "s/&/\&/g; s//\>/g" } # Convert file path to safe filename for HTML function bashunit::coverage::path_to_filename() { local file="$1" local display_file="${file#"$(pwd)"/}" # Replace / with _ and . with _ local safe_name="${display_file//\//_}" echo "${safe_name//./_}" } function bashunit::coverage::report_html() { local output_dir="${1:-coverage/html}" if [ -z "$output_dir" ]; then return 0 fi # Create output directory structure mkdir -p "$output_dir/files" # Collect file data for index local IFS=$' \t\n' local total_executable=0 local total_hit=0 local -a file_data=() local file_data_count=0 local file="" while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue local stats executable hit pct stats=$(bashunit::coverage::get_cached_stats "$file") executable="${stats%%:*}" stats="${stats#*:}" hit="${stats%%:*}" stats="${stats#*:}" pct="${stats%%:*}" total_executable=$((total_executable + executable)) total_hit=$((total_hit + hit)) local display_file="${file#"$(pwd)"/}" local safe_filename safe_filename=$(bashunit::coverage::path_to_filename "$file") file_data[file_data_count]="$display_file|$hit|$executable|$pct|$safe_filename" file_data_count=$((file_data_count + 1)) # Generate individual file HTML bashunit::coverage::generate_file_html "$file" "$output_dir/files/${safe_filename}.html" done < <(bashunit::coverage::get_tracked_files) # Calculate total percentage local total_pct total_pct=$(bashunit::coverage::calculate_percentage "$total_hit" "$total_executable") # Get test results local tests_passed tests_failed tests_total tests_passed=$(bashunit::state::get_tests_passed) tests_failed=$(bashunit::state::get_tests_failed) tests_total=$((tests_passed + tests_failed)) # Generate index.html bashunit::coverage::generate_index_html \ "$output_dir/index.html" "$total_hit" "$total_executable" "$total_pct" \ "$tests_total" "$tests_passed" "$tests_failed" ${file_data[@]+"${file_data[@]}"} echo "Coverage HTML report written to: $output_dir/index.html" } function bashunit::coverage::generate_index_html() { # Set normal IFS for array operations throughout the function (Bash 3.0/4.3 compatible) local IFS=$' \t\n' local output_file="$1" local total_hit="$2" local total_executable="$3" local total_pct="$4" local tests_total="$5" local tests_passed="$6" local tests_failed="$7" shift 7 # Handle array passed as arguments - Bash 3.0 compatible local -a file_data=() local file_count=0 if [ $# -gt 0 ]; then file_data=("$@") file_count=$# fi # Calculate uncovered lines and file count local total_uncovered=$((total_executable - total_hit)) # Calculate gauge stroke offset (440 is full circle circumference) local gauge_offset=$((440 - (440 * total_pct / 100))) # Determine coverage level and colors for gauge local total_class gauge_color_start gauge_color_end gauge_text_gradient total_class=$(bashunit::coverage::get_coverage_class "$total_pct") case "$total_class" in high) gauge_color_start="#10b981" gauge_color_end="#34d399" gauge_text_gradient="linear-gradient(135deg, #10b981 0%, #34d399 100%)" ;; medium) gauge_color_start="#f59e0b" gauge_color_end="#fbbf24" gauge_text_gradient="linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)" ;; low) gauge_color_start="#ef4444" gauge_color_end="#f87171" gauge_text_gradient="linear-gradient(135deg, #ef4444 0%, #f87171 100%)" ;; esac { cat <<'EOF' Coverage Report | bashunit
EOF echo "
v${BASHUNIT_VERSION:-0.0.0}
" cat <<'EOF'

Code Coverage Report

Comprehensive line-by-line coverage analysis for your bash scripts

EOF echo " " echo " " cat <<'EOF' EOF echo " " cat <<'EOF'
EOF echo "
${total_pct}%
" cat <<'EOF'
Coverage

Overall Code Coverage

EOF echo "

${total_hit} of ${total_executable} executable lines covered across ${file_count} files.

" cat <<'EOF'
Coverage Metrics
Total: EOF echo " ${total_executable} lines" cat <<'EOF'
Covered: EOF echo " ${total_hit} lines" cat <<'EOF'
Uncovered: EOF echo " ${total_uncovered} lines" cat <<'EOF'
Test Results
Files: EOF echo " ${file_count}" cat <<'EOF'
Tests: EOF echo " ${tests_total} total" cat <<'EOF'
Passed: EOF echo " ${tests_passed}" cat <<'EOF'
Failed: EOF echo " ${tests_failed}" cat <<'EOF'

File Coverage Details

EOF echo " ≥${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High" cat <<'EOF'
EOF echo " ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium" cat <<'EOF'
EOF echo " <${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low" cat <<'EOF'
EOF local data display_file hit executable pct safe_filename for data in ${file_data[@]+"${file_data[@]}"}; do IFS='|' read -r display_file hit executable pct safe_filename <<<"$data" local class class=$(bashunit::coverage::get_coverage_class "$pct") echo " " echo " " echo " " echo " " echo " " done cat <<'EOF'
File Lines Coverage
" echo "
" echo " $(basename "$display_file")" echo "
./${display_file}
" echo "
" echo "
" echo "
" echo "
${hit}
" echo "
of ${executable} lines
" echo "
" echo "
" echo "
" echo "
" echo "
" echo "
" echo " ${pct}%" echo "
" echo "
EOF } >"$output_file" } function bashunit::coverage::generate_file_html() { local file="$1" local output_file="$2" local display_file="${file#"$(pwd)"/}" local executable hit pct class stats rest stats=$(bashunit::coverage::get_cached_stats "$file") executable="${stats%%:*}" rest="${stats#*:}" hit="${rest%%:*}" rest="${rest#*:}" pct="${rest%%:*}" class="${rest#*:}" local uncovered=$((executable - hit)) # Pre-load all line hits into indexed array (performance optimization) local -a hits_by_line=() local _ln _cnt while IFS=: read -r _ln _cnt; do hits_by_line[_ln]=$_cnt done < <(bashunit::coverage::get_all_line_hits "$file") # Pre-load all file lines into indexed array (avoids sed per line) local -a file_lines=() local _fli=0 _fl while IFS= read -r _fl || [ -n "$_fl" ]; do file_lines[_fli]="$_fl" ((++_fli)) done <"$file" # Pre-load test hits data into indexed array (for tooltips) # Index: line number, Value: newline-separated list of "test_file:test_function" # Using indexed array for Bash 3.0 compatibility (no associative arrays) local -a tests_by_line=() local _line_and_test while IFS= read -r _line_and_test; do [ -z "$_line_and_test" ] && continue local _tln="${_line_and_test%%|*}" local _tinfo="${_line_and_test#*|}" if [ -n "${tests_by_line[_tln]:-}" ]; then # Append only if not already present (avoid duplicates) # Use newline boundaries to prevent false positives (e.g., test_foo matching test_foo_bar) case $'\n'"${tests_by_line[_tln]}"$'\n' in *$'\n'"$_tinfo"$'\n'*) # already present, skip ;; *) tests_by_line[_tln]="${tests_by_line[_tln]}"$'\n'"${_tinfo}" ;; esac else tests_by_line[_tln]="$_tinfo" fi done < <(bashunit::coverage::get_all_line_tests "$file") # Count total lines and functions local total_lines total_lines=$(wc -l <"$file" | tr -d ' ') local non_executable=$((total_lines - executable)) { cat <<'EOF' EOF echo " $(basename "$display_file") | Coverage Report" cat <<'EOF'
EOF echo " ${pct}%" cat <<'EOF' Coverage
EOF echo " ${hit}/${executable}" cat <<'EOF' Lines
Line Coverage Progress EOF echo " ${pct}%" cat <<'EOF'
EOF echo "
" cat <<'EOF'
EOF echo " ${hit} lines covered" cat <<'EOF'
EOF echo " ${uncovered} lines uncovered" cat <<'EOF'
EOF echo " ${non_executable} non-executable" cat <<'EOF'
EOF # Extract functions and generate summary table local functions_data functions_data=$(bashunit::coverage::extract_functions "$file") if [ -n "$functions_data" ]; then cat <<'EOF'
EOF local fn_entry while IFS= read -r fn_entry; do [ -z "$fn_entry" ] && continue local fn_name fn_start fn_end fn_name="${fn_entry%%|*}" local rest="${fn_entry#*|}" fn_start="${rest%%|*}" fn_end="${rest#*|}" # Calculate function coverage using pre-loaded hits data local fn_executable=0 local fn_hit=0 local ln for ((ln = fn_start; ln <= fn_end; ln++)); do local ln_content ln_content="${file_lines[$((ln - 1))]:-}" if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then ((++fn_executable)) local ln_hits=${hits_by_line[$ln]:-0} if [ "$ln_hits" -gt 0 ]; then ((++fn_hit)) fi fi done local fn_pct fn_class row_class fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable") fn_class=$(bashunit::coverage::get_coverage_class "$fn_pct") case "$fn_class" in high) row_class="fn-covered" ;; medium) row_class="fn-partial" ;; low) row_class="fn-uncovered" ;; esac echo " " echo " " echo " " echo " " echo " " done <<<"$functions_data" cat <<'EOF'
Function Lines Coverage
${fn_name}${fn_hit} / ${fn_executable}" echo "
" echo "
" echo " ${fn_pct}%" echo "
" echo "
EOF fi cat <<'EOF'
EOF echo " ./${display_file}" echo "
" echo " ${total_lines} total lines" echo "
" cat <<'EOF'
EOF local lineno=0 local line for line in "${file_lines[@]}"; do ((++lineno)) local escaped_line escaped_line=$(bashunit::coverage::html_escape "$line") local row_class="" local hits_display="" if bashunit::coverage::is_executable_line "$line" "$lineno"; then # O(1) lookup from pre-loaded array local hits=${hits_by_line[$lineno]:-0} if [ "$hits" -gt 0 ]; then row_class="covered" # Check if we have test info for this line local test_info="${tests_by_line[$lineno]:-}" if [ -n "$test_info" ]; then # Build tooltip with test information local tooltip_html="
Tests hitting this line
    " local test_file test_fn while IFS=':' read -r test_file test_fn; do [ -z "$test_file" ] && continue local short_file short_file=$(basename "$test_file") tooltip_html="$tooltip_html
  • ${short_file}:${test_fn}
  • " done <<<"$test_info" tooltip_html="$tooltip_html
" hits_display="${hits}×${tooltip_html}" else hits_display="${hits}×" fi else row_class="uncovered" hits_display="${hits}×" fi fi echo " " echo " " echo " " echo " " echo " " done cat <<'EOF'
$lineno$hits_display$escaped_line
EOF } >"$output_file" } function bashunit::coverage::cleanup() { if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then local coverage_dir coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") rm -rf "$coverage_dir" fi } # clock.sh _BASHUNIT_CLOCK_NOW_IMPL="" function bashunit::clock::_choose_impl() { local shell_time # Use explicit indices for Bash 3.0 compatibility (empty array access fails with set -u) local attempts_count=0 local attempts # 1. Try native shell EPOCHREALTIME (fastest - no subprocess, Bash 5.0+) attempts[attempts_count]="EPOCHREALTIME" attempts_count=$((attempts_count + 1)) if shell_time="$(bashunit::clock::shell_time)"; then _BASHUNIT_CLOCK_NOW_IMPL="shell" return 0 fi # 2. Unix date +%s%N (no subprocess overhead on supported systems) attempts[attempts_count]="date" attempts_count=$((attempts_count + 1)) if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then local result result=$(date +%s%N 2>/dev/null) # A pure-digit result means %N expanded; a literal "N" (unsupported date) # contains a non-digit, so the digits-only check alone is sufficient. case "$result" in '' | *[!0-9]*) ;; *) _BASHUNIT_CLOCK_NOW_IMPL="date" return 0 ;; esac fi # 3. Try Perl with Time::HiRes. Probe by reading the actual time (not an empty # `-e ""`) so the pending first read reuses this fork instead of paying a # second one; a non-digit/empty result means perl or Time::HiRes is missing, so # fall through. The value is seeded into the return slot for now_to_slot. attempts[attempts_count]="Perl" attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_perl; then local perl_now perl_now="$(perl -MTime::HiRes -e 'printf("%.0f\n", Time::HiRes::time() * 1000000000)' 2>/dev/null)" case "$perl_now" in '' | *[!0-9]*) ;; *) _BASHUNIT_CLOCK_NOW_IMPL="perl" _BASHUNIT_CLOCK_NOW_OUT="$perl_now" return 0 ;; esac fi # 4. Try Python 3 with time module attempts[attempts_count]="Python" attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_python; then _BASHUNIT_CLOCK_NOW_IMPL="python" return 0 fi # 5. Try Node.js attempts[attempts_count]="Node" attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_node; then _BASHUNIT_CLOCK_NOW_IMPL="node" return 0 fi # 6. Windows fallback with PowerShell attempts[attempts_count]="PowerShell" attempts_count=$((attempts_count + 1)) if bashunit::check_os::is_windows && bashunit::dependencies::has_powershell; then _BASHUNIT_CLOCK_NOW_IMPL="powershell" return 0 fi # 7. Very last fallback: seconds resolution only attempts[attempts_count]="date-seconds" attempts_count=$((attempts_count + 1)) if date +%s &>/dev/null; then _BASHUNIT_CLOCK_NOW_IMPL="date-seconds" return 0 fi # 8. All methods failed printf "bashunit::clock::now implementations tried: %s\n" "${attempts[*]}" >&2 echo "" return 1 } # Returns 0 when the chosen clock impl forks an interpreter (perl/python/node/ # powershell), so callers can skip optional timing to avoid a per-read fork (#765). function bashunit::clock::is_expensive() { [ -n "$_BASHUNIT_CLOCK_NOW_IMPL" ] || bashunit::clock::_choose_impl >/dev/null 2>&1 || true case "$_BASHUNIT_CLOCK_NOW_IMPL" in perl | python | node | powershell) return 0 ;; *) return 1 ;; esac } _BASHUNIT_CLOCK_NOW_OUT="" # Return-slot variant of bashunit::clock::now: writes the current time in # nanoseconds into _BASHUNIT_CLOCK_NOW_OUT. The `shell` branch reads # EPOCHREALTIME directly (folding in shell_time) so the per-test hot path pays # no command-substitution fork on Bash 5.0+; `date-seconds` forks once instead # of twice. Interpreter/`date` branches keep a single internal fork. # Returns: 0 on success, 1 when no clock implementation is available. function bashunit::clock::now_to_slot() { if [ -z "$_BASHUNIT_CLOCK_NOW_IMPL" ]; then _BASHUNIT_CLOCK_NOW_OUT="" bashunit::clock::_choose_impl || return 1 # _choose_impl may have already read the current time while selecting an # interpreter impl (e.g. perl); reuse that value for this first read instead # of forking a second interpreter. if [ -n "$_BASHUNIT_CLOCK_NOW_OUT" ]; then return 0 fi fi case "$_BASHUNIT_CLOCK_NOW_IMPL" in perl) _BASHUNIT_CLOCK_NOW_OUT="$(perl -MTime::HiRes -e 'printf("%.0f\n", Time::HiRes::time() * 1000000000)')" ;; python) _BASHUNIT_CLOCK_NOW_OUT="$( python - <<'EOF' import time, sys sys.stdout.write(str(int(time.time() * 1000000000))) EOF )" ;; node) _BASHUNIT_CLOCK_NOW_OUT="$(node -e 'process.stdout.write((BigInt(Date.now()) * 1000000n).toString())')" ;; powershell) _BASHUNIT_CLOCK_NOW_OUT="$(powershell -Command "\ \$unixEpoch = [DateTime]'1970-01-01 00:00:00';\ \$now = [DateTime]::UtcNow;\ \$ticksSinceEpoch = (\$now - \$unixEpoch).Ticks;\ \$nanosecondsSinceEpoch = \$ticksSinceEpoch * 100;\ Write-Output \$nanosecondsSinceEpoch\ ")" ;; date) _BASHUNIT_CLOCK_NOW_OUT="$(date +%s%N)" ;; date-seconds) local seconds seconds=$(date +%s) _BASHUNIT_CLOCK_NOW_OUT="$((seconds * 1000000000))" ;; shell) # Read EPOCHREALTIME directly (no shell_time subshell) on the hot path; # both '.' and ',' decimal separators are handled for locale portability. local shell_time="${EPOCHREALTIME:-}" local seconds="${shell_time%%[.,]*}" local microseconds="${shell_time#*[.,]}" if [ "$seconds" = "$shell_time" ]; then microseconds="" fi # Pad to 6 digits and strip leading zeros for arithmetic microseconds="${microseconds}000000" microseconds="${microseconds:0:6}" microseconds="${microseconds#"${microseconds%%[!0]*}"}" microseconds="${microseconds:-0}" _BASHUNIT_CLOCK_NOW_OUT="$(((seconds * 1000000000) + (microseconds * 1000)))" ;; *) bashunit::clock::_choose_impl || return 1 bashunit::clock::now_to_slot ;; esac } function bashunit::clock::now() { bashunit::clock::now_to_slot || return 1 echo "$_BASHUNIT_CLOCK_NOW_OUT" } function bashunit::clock::shell_time() { # Get time directly from the shell variable EPOCHREALTIME (Bash 5+) [ -n "${EPOCHREALTIME+x}" ] && [ -n "$EPOCHREALTIME" ] && LC_ALL=C echo "$EPOCHREALTIME" } function bashunit::clock::total_runtime_in_milliseconds() { local end_time end_time=$(bashunit::clock::now) if [ -n "$end_time" ]; then bashunit::math::calculate "($end_time - $_BASHUNIT_START_TIME) / 1000000" else echo "" fi } function bashunit::clock::total_runtime_in_nanoseconds() { local end_time end_time=$(bashunit::clock::now) if [ -n "$end_time" ]; then bashunit::math::calculate "$end_time - $_BASHUNIT_START_TIME" else echo "" fi } function bashunit::clock::init() { _BASHUNIT_START_TIME=$(bashunit::clock::now) } # state.sh # Cache base64 -w flag support (Alpine needs -w 0, macOS does not support -w). # Scrape `base64 --help` once and match with a shell `case` instead of piping # into a `grep` fork — same detection, one fewer fork per cold start. _bashunit_base64_help="$(base64 --help 2>&1 || true)" case "$_bashunit_base64_help" in *-w*) _BASHUNIT_BASE64_WRAP_FLAG=true ;; *) _BASHUNIT_BASE64_WRAP_FLAG=false ;; esac unset _bashunit_base64_help _BASHUNIT_TESTS_PASSED=0 _BASHUNIT_TESTS_FAILED=0 _BASHUNIT_TESTS_SKIPPED=0 _BASHUNIT_TESTS_INCOMPLETE=0 _BASHUNIT_TESTS_SNAPSHOT=0 _BASHUNIT_TESTS_RISKY=0 _BASHUNIT_ASSERTIONS_PASSED=0 _BASHUNIT_ASSERTIONS_FAILED=0 _BASHUNIT_ASSERTIONS_SKIPPED=0 _BASHUNIT_ASSERTIONS_INCOMPLETE=0 _BASHUNIT_ASSERTIONS_SNAPSHOT=0 _BASHUNIT_DUPLICATED_FUNCTION_NAMES="" _BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES="" _BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND=false _BASHUNIT_TEST_OUTPUT="" _BASHUNIT_TEST_TITLE="" _BASHUNIT_TEST_EXIT_CODE=0 _BASHUNIT_TEST_HOOK_FAILURE="" _BASHUNIT_TEST_HOOK_MESSAGE="" _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="" _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 function bashunit::state::get_tests_passed() { echo "$_BASHUNIT_TESTS_PASSED" } function bashunit::state::add_tests_passed() { ((_BASHUNIT_TESTS_PASSED++)) || true } function bashunit::state::get_tests_failed() { echo "$_BASHUNIT_TESTS_FAILED" } function bashunit::state::add_tests_failed() { ((_BASHUNIT_TESTS_FAILED++)) || true } function bashunit::state::get_tests_skipped() { echo "$_BASHUNIT_TESTS_SKIPPED" } function bashunit::state::add_tests_skipped() { ((_BASHUNIT_TESTS_SKIPPED++)) || true } function bashunit::state::get_tests_incomplete() { echo "$_BASHUNIT_TESTS_INCOMPLETE" } function bashunit::state::add_tests_incomplete() { ((_BASHUNIT_TESTS_INCOMPLETE++)) || true } function bashunit::state::get_tests_snapshot() { echo "$_BASHUNIT_TESTS_SNAPSHOT" } function bashunit::state::add_tests_snapshot() { ((_BASHUNIT_TESTS_SNAPSHOT++)) || true } function bashunit::state::get_tests_risky() { echo "$_BASHUNIT_TESTS_RISKY" } function bashunit::state::add_tests_risky() { ((_BASHUNIT_TESTS_RISKY++)) || true } function bashunit::state::get_assertions_passed() { echo "$_BASHUNIT_ASSERTIONS_PASSED" } function bashunit::state::add_assertions_passed() { ((_BASHUNIT_ASSERTIONS_PASSED++)) || true } function bashunit::state::get_assertions_failed() { echo "$_BASHUNIT_ASSERTIONS_FAILED" } function bashunit::state::add_assertions_failed() { ((_BASHUNIT_ASSERTIONS_FAILED++)) || true } function bashunit::state::get_assertions_skipped() { echo "$_BASHUNIT_ASSERTIONS_SKIPPED" } function bashunit::state::add_assertions_skipped() { ((_BASHUNIT_ASSERTIONS_SKIPPED++)) || true } function bashunit::state::get_assertions_incomplete() { echo "$_BASHUNIT_ASSERTIONS_INCOMPLETE" } function bashunit::state::add_assertions_incomplete() { ((_BASHUNIT_ASSERTIONS_INCOMPLETE++)) || true } function bashunit::state::get_assertions_snapshot() { echo "$_BASHUNIT_ASSERTIONS_SNAPSHOT" } function bashunit::state::add_assertions_snapshot() { ((_BASHUNIT_ASSERTIONS_SNAPSHOT++)) || true } function bashunit::state::is_duplicated_test_functions_found() { echo "$_BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND" } function bashunit::state::set_duplicated_test_functions_found() { _BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND=true } function bashunit::state::get_duplicated_function_names() { echo "$_BASHUNIT_DUPLICATED_FUNCTION_NAMES" } function bashunit::state::set_duplicated_function_names() { _BASHUNIT_DUPLICATED_FUNCTION_NAMES="$1" } function bashunit::state::get_file_with_duplicated_function_names() { echo "$_BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES" } function bashunit::state::set_file_with_duplicated_function_names() { _BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES="$1" } function bashunit::state::add_test_output() { _BASHUNIT_TEST_OUTPUT="$_BASHUNIT_TEST_OUTPUT$1" } function bashunit::state::get_test_exit_code() { echo "$_BASHUNIT_TEST_EXIT_CODE" } function bashunit::state::set_test_exit_code() { _BASHUNIT_TEST_EXIT_CODE="$1" } function bashunit::state::get_test_title() { echo "$_BASHUNIT_TEST_TITLE" } function bashunit::state::set_test_title() { _BASHUNIT_TEST_TITLE="$1" } function bashunit::state::reset_test_title() { _BASHUNIT_TEST_TITLE="" } function bashunit::state::get_current_test_interpolated_function_name() { echo "$_BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME" } function bashunit::state::set_current_test_interpolated_function_name() { _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="$1" } function bashunit::state::reset_current_test_interpolated_function_name() { _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="" } function bashunit::state::get_test_hook_failure() { echo "$_BASHUNIT_TEST_HOOK_FAILURE" } function bashunit::state::set_test_hook_failure() { _BASHUNIT_TEST_HOOK_FAILURE="$1" } function bashunit::state::reset_test_hook_failure() { _BASHUNIT_TEST_HOOK_FAILURE="" } function bashunit::state::get_test_hook_message() { echo "$_BASHUNIT_TEST_HOOK_MESSAGE" } function bashunit::state::set_test_hook_message() { _BASHUNIT_TEST_HOOK_MESSAGE="$1" } function bashunit::state::reset_test_hook_message() { _BASHUNIT_TEST_HOOK_MESSAGE="" } function bashunit::state::is_assertion_failed_in_test() { ((_BASHUNIT_ASSERTION_FAILED_IN_TEST)) } function bashunit::state::mark_assertion_failed_in_test() { _BASHUNIT_ASSERTION_FAILED_IN_TEST=1 } function bashunit::state::set_duplicated_functions_merged() { bashunit::state::set_duplicated_test_functions_found bashunit::state::set_file_with_duplicated_function_names "$1" bashunit::state::set_duplicated_function_names "$2" } function bashunit::state::initialize_assertions_count() { _BASHUNIT_ASSERTIONS_PASSED=0 _BASHUNIT_ASSERTIONS_FAILED=0 _BASHUNIT_ASSERTIONS_SKIPPED=0 _BASHUNIT_ASSERTIONS_INCOMPLETE=0 _BASHUNIT_ASSERTIONS_SNAPSHOT=0 _BASHUNIT_TEST_OUTPUT="" _BASHUNIT_TEST_TITLE="" _BASHUNIT_TEST_HOOK_FAILURE="" _BASHUNIT_TEST_HOOK_MESSAGE="" _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 } # base64-encodes a field, writing the result into _BASHUNIT_STATE_ENCODED_OUT. # Empty values (the common case for title/hook message, and output on a passing # test) encode to an empty field with no base64 fork (#762). base64 of "" is "" # anyway, so this stays wire-compatible. _BASHUNIT_STATE_ENCODED_OUT="" function bashunit::state::encode_field() { local value=$1 if [ -z "$value" ]; then _BASHUNIT_STATE_ENCODED_OUT="" return fi if [ "$_BASHUNIT_BASE64_WRAP_FLAG" = true ]; then # Alpine requires the -w 0 option to avoid wrapping _BASHUNIT_STATE_ENCODED_OUT=$(echo -n "$value" | base64 -w 0) else _BASHUNIT_STATE_ENCODED_OUT=$(echo -n "$value" | base64) fi } function bashunit::state::export_subshell_context() { local encoded_test_output local encoded_test_title local encoded_test_hook_message bashunit::state::encode_field "$_BASHUNIT_TEST_OUTPUT" encoded_test_output=$_BASHUNIT_STATE_ENCODED_OUT bashunit::state::encode_field "$_BASHUNIT_TEST_TITLE" encoded_test_title=$_BASHUNIT_STATE_ENCODED_OUT bashunit::state::encode_field "$_BASHUNIT_TEST_HOOK_MESSAGE" encoded_test_hook_message=$_BASHUNIT_STATE_ENCODED_OUT # Emit the encoded result payload with `printf` (a builtin) instead of a # `cat <`. ## function bashunit::console_header::print_random_order_seed() { local seed=$1 printf "%sRandomized with seed:%s %s\n" \ "${_BASHUNIT_COLOR_INCOMPLETE}" "${_BASHUNIT_COLOR_DEFAULT}" "$seed" } function bashunit::console_header::print_version() { local filter=${1:-} shift || true # Bash 3.0 compatible: check argument count after shift local files_count=$# local total_tests if [ "$files_count" -eq 0 ]; then total_tests=0 elif bashunit::parallel::is_enabled && bashunit::env::is_simple_output_enabled; then # Skip counting in parallel+simple mode for faster startup total_tests=0 else # Read via the return slot, not $(...): the capture subshell would discard # the provider-map cache find_total_tests builds per file, forcing the # runner to re-scan each file with a second awk fork. bashunit::helper::find_total_tests "$filter" "$@" >/dev/null total_tests=$_BASHUNIT_HELPER_TOTAL_TESTS_OUT fi if bashunit::env::is_header_ascii_art_enabled; then cat < [arguments] [options] Commands: test [path] Run tests (default command) bench [path] Run benchmarks assert Run standalone assertion doc [filter] Display assertion documentation init [dir] Initialize a new test directory learn Start interactive tutorial watch [path] Watch files and re-run tests on change upgrade Upgrade bashunit to latest version Global Options: -h, --help Show this help message -v, --version Display the current version Run 'bashunit --help' for command-specific options. Examples: bashunit test tests/ Run all tests in directory bashunit tests/ Run all tests (shorthand) bashunit bench Run all benchmarks bashunit assert equals "foo" "foo" Run standalone assertion bashunit doc contains Show docs for 'contains' assertions bashunit init Initialize test directory More info: https://bashunit.com/command-line EOF } function bashunit::console_header::print_test_help() { cat < Run a standalone assert function (deprecated: use 'bashunit assert') -e, --env, --boot Load a custom env/bootstrap file (supports args) -f, --filter Only run tests matching the name --tag Only run tests with matching @tag (repeatable, OR logic) --exclude-tag Skip tests with matching @tag (repeatable, exclude wins) --log-junit, --report-junit Write JUnit XML report -j, --jobs Run tests in parallel with max N concurrent jobs ("auto" = CPU cores) -p, --parallel Run tests in parallel (unlimited concurrency) --no-parallel Run tests sequentially -r, --report-html Write HTML report --report-tap Write TAP version 13 report --report-json Write machine-readable JSON report -s, --simple Simple output (dots) --detailed Detailed output (default) --output Output format: tap (TAP version 13) -R, --run-all Run all assertions (don't stop on first failure) -S, --stop-on-failure Stop on first failure --test-timeout Fail a test if it runs longer than N seconds (0 = off) --retry Re-run a failed test up to N extra times (0 = off) --random-order Randomize test execution order --seed Seed for --random-order (reproducible shuffle) --shard / Run shard i of n (split the suite across runners) --rerun-failed Replay only the tests that failed on the last run (.bashunit/last-failed) -vvv, --verbose Show execution details --debug [file] Enable shell debug mode --no-output Suppress all output --failures-only Only show failures (suppress passed/skipped/incomplete) --fail-on-risky Treat risky tests (no assertions) as failures --profile Report the slowest tests (count: BASHUNIT_PROFILE_COUNT, default 10) --no-progress Suppress real-time progress, show only final results --show-output Show test output on failure (default: enabled) --no-output-on-failure Hide test output on failure --strict Enable strict shell mode (set -euo pipefail) --skip-env-file Skip .env loading, use shell environment only -l, --login Run tests in login shell context -w, --watch Watch for changes and re-run tests --no-color Disable colored output (honors NO_COLOR env var) -h, --help Show this help message Coverage: --coverage Enable code coverage tracking --coverage-paths Source paths to track (default: auto-discover) --coverage-exclude Patterns to exclude (comma-separated) --coverage-report [file] Output file (default: coverage/lcov.info) --coverage-report-html [dir] HTML report (default: coverage/html) --coverage-min Fail if coverage below percentage --no-coverage-report Disable file output, console only Examples: bashunit test tests/ bashunit test tests/unit/ --parallel bashunit test --filter "user" tests/ bashunit test -a equals "foo" "foo" bashunit test tests/ --coverage bashunit test tests/ --coverage --coverage-min 80 bashunit test tests/ --coverage-report-html EOF } function bashunit::console_header::print_bench_help() { cat < Load a custom env/bootstrap file (supports args) -f, --filter Only run benchmarks matching the name -s, --simple Simple output --detailed Detailed output (default) -vvv, --verbose Show execution details --skip-env-file Skip .env loading, use shell environment only -l, --login Run in login shell context --no-color Disable colored output (honors NO_COLOR env var) -h, --help Show this help message Examples: bashunit bench bashunit bench benchmarks/ bashunit bench --filter "parse" EOF } function bashunit::console_header::print_doc_help() { cat < [args...] bashunit assert "" [ ...] Run standalone assertion(s) without creating a test file. Single assertion: bashunit assert equals "foo" "foo" bashunit assert same "1" "1" bashunit assert contains "world" "hello world" bashunit assert exit_code 0 "echo 'success'" Multiple assertions on command output: bashunit assert "echo 'error' && exit 1" exit_code "1" contains "error" bashunit assert "./my_script.sh" exit_code "0" contains "success" not_contains "error" Arguments: function Assertion function name (with or without 'assert_' prefix) command Command to execute (for multi-assertion mode) assertion Assertion name (exit_code, contains, equals, etc.) arg Expected value for the assertion Note: You can also use 'bashunit test --assert ' (deprecated). The 'bashunit assert' subcommand is the recommended approach. More info: https://bashunit.com/standalone EOF } function bashunit::console_header::print_watch_help() { cat << 'ENDOFHELP' Usage: bashunit watch [path] [test-options] Watch .sh files for changes and automatically re-run tests. Arguments: [path] Directory or file to watch and test (default: .) Options: -h, --help Show this help message Any option accepted by 'bashunit test' is also accepted here. Requirements: Linux: inotifywait (sudo apt install inotify-tools) macOS: fswatch (brew install fswatch) Examples: bashunit watch Watch current directory bashunit watch tests/ Watch the tests/ directory bashunit watch tests/ --filter user Watch and filter by name bashunit watch tests/ --simple Watch with simple output ENDOFHELP } # console_results.sh # shellcheck disable=SC2155 _BASHUNIT_TOTAL_TESTS_COUNT=0 function bashunit::console_results::render_result() { if [ "$(bashunit::state::is_duplicated_test_functions_found)" = true ]; then bashunit::console_results::print_execution_time printf "%s%s%s\n" "${_BASHUNIT_COLOR_RETURN_ERROR}" "Duplicate test functions found" "${_BASHUNIT_COLOR_DEFAULT}" printf "File with duplicate functions: %s\n" "$(bashunit::state::get_file_with_duplicated_function_names)" printf "Duplicate functions: %s\n" "$(bashunit::state::get_duplicated_function_names)" return 1 fi if bashunit::env::is_tap_output_enabled; then printf "1..%d\n" "$_BASHUNIT_TOTAL_TESTS_COUNT" if [ "$_BASHUNIT_TESTS_FAILED" -gt 0 ]; then return 1 fi return 0 fi if bashunit::env::is_simple_output_enabled; then printf "\n\n" fi # Cache state values to avoid repeated subshell invocations local tests_passed=$_BASHUNIT_TESTS_PASSED local tests_skipped=$_BASHUNIT_TESTS_SKIPPED local tests_incomplete=$_BASHUNIT_TESTS_INCOMPLETE local tests_snapshot=$_BASHUNIT_TESTS_SNAPSHOT local tests_failed=$_BASHUNIT_TESTS_FAILED local tests_risky=$_BASHUNIT_TESTS_RISKY local assertions_passed=$_BASHUNIT_ASSERTIONS_PASSED local assertions_skipped=$_BASHUNIT_ASSERTIONS_SKIPPED local assertions_incomplete=$_BASHUNIT_ASSERTIONS_INCOMPLETE local assertions_snapshot=$_BASHUNIT_ASSERTIONS_SNAPSHOT local assertions_failed=$_BASHUNIT_ASSERTIONS_FAILED local total_tests=0 total_tests=$((total_tests + tests_passed)) total_tests=$((total_tests + tests_skipped)) total_tests=$((total_tests + tests_incomplete)) total_tests=$((total_tests + tests_snapshot)) total_tests=$((total_tests + tests_failed)) total_tests=$((total_tests + tests_risky)) local total_assertions=0 total_assertions=$((total_assertions + assertions_passed)) total_assertions=$((total_assertions + assertions_skipped)) total_assertions=$((total_assertions + assertions_incomplete)) total_assertions=$((total_assertions + assertions_snapshot)) total_assertions=$((total_assertions + assertions_failed)) printf "%sTests: %s" "$_BASHUNIT_COLOR_FAINT" "$_BASHUNIT_COLOR_DEFAULT" if [ "$tests_passed" -gt 0 ] || [ "$assertions_passed" -gt 0 ]; then printf " %s%s passed%s," "$_BASHUNIT_COLOR_PASSED" "$tests_passed" "$_BASHUNIT_COLOR_DEFAULT" fi if [ "$tests_skipped" -gt 0 ] || [ "$assertions_skipped" -gt 0 ]; then printf " %s%s skipped%s," "$_BASHUNIT_COLOR_SKIPPED" "$tests_skipped" "$_BASHUNIT_COLOR_DEFAULT" fi if [ "$tests_incomplete" -gt 0 ] || [ "$assertions_incomplete" -gt 0 ]; then printf " %s%s incomplete%s," "$_BASHUNIT_COLOR_INCOMPLETE" "$tests_incomplete" "$_BASHUNIT_COLOR_DEFAULT" fi if [ "$tests_snapshot" -gt 0 ] || [ "$assertions_snapshot" -gt 0 ]; then printf " %s%s snapshot%s," "$_BASHUNIT_COLOR_SNAPSHOT" "$tests_snapshot" "$_BASHUNIT_COLOR_DEFAULT" fi if [ "$tests_failed" -gt 0 ] || [ "$assertions_failed" -gt 0 ]; then printf " %s%s failed%s," "$_BASHUNIT_COLOR_FAILED" "$tests_failed" "$_BASHUNIT_COLOR_DEFAULT" fi if [ "$tests_risky" -gt 0 ]; then printf " %s%s risky%s," "$_BASHUNIT_COLOR_RISKY" "$tests_risky" "$_BASHUNIT_COLOR_DEFAULT" fi printf " %s total\n" "$total_tests" printf "%sAssertions:%s" "$_BASHUNIT_COLOR_FAINT" "$_BASHUNIT_COLOR_DEFAULT" if [ "$tests_passed" -gt 0 ] || [ "$assertions_passed" -gt 0 ]; then printf " %s%s passed%s," "$_BASHUNIT_COLOR_PASSED" "$assertions_passed" "$_BASHUNIT_COLOR_DEFAULT" fi if [ "$tests_skipped" -gt 0 ] || [ "$assertions_skipped" -gt 0 ]; then printf " %s%s skipped%s," "$_BASHUNIT_COLOR_SKIPPED" "$assertions_skipped" "$_BASHUNIT_COLOR_DEFAULT" fi if [ "$tests_incomplete" -gt 0 ] || [ "$assertions_incomplete" -gt 0 ]; then printf " %s%s incomplete%s," "$_BASHUNIT_COLOR_INCOMPLETE" "$assertions_incomplete" "$_BASHUNIT_COLOR_DEFAULT" fi if [ "$tests_snapshot" -gt 0 ] || [ "$assertions_snapshot" -gt 0 ]; then printf " %s%s snapshot%s," "$_BASHUNIT_COLOR_SNAPSHOT" "$assertions_snapshot" "$_BASHUNIT_COLOR_DEFAULT" fi if [ "$tests_failed" -gt 0 ] || [ "$assertions_failed" -gt 0 ]; then printf " %s%s failed%s," "$_BASHUNIT_COLOR_FAILED" "$assertions_failed" "$_BASHUNIT_COLOR_DEFAULT" fi printf " %s total\n" "$total_assertions" if [ "$tests_failed" -gt 0 ]; then printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_ERROR" " Some tests failed " "$_BASHUNIT_COLOR_DEFAULT" bashunit::console_results::print_execution_time return 1 fi if [ "$tests_risky" -gt 0 ]; then printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_RISKY" " Some tests risky (no assertions) " "$_BASHUNIT_COLOR_DEFAULT" bashunit::console_results::print_execution_time return 0 fi if [ "$tests_incomplete" -gt 0 ]; then printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_INCOMPLETE" " Some tests incomplete " "$_BASHUNIT_COLOR_DEFAULT" bashunit::console_results::print_execution_time return 0 fi if [ "$tests_skipped" -gt 0 ]; then printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_SKIPPED" " Some tests skipped " "$_BASHUNIT_COLOR_DEFAULT" bashunit::console_results::print_execution_time return 0 fi if [ "$tests_snapshot" -gt 0 ]; then printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_SNAPSHOT" " Some snapshots created " "$_BASHUNIT_COLOR_DEFAULT" bashunit::console_results::print_execution_time return 0 fi if [ "$total_tests" -eq 0 ]; then printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_ERROR" " No tests found " "$_BASHUNIT_COLOR_DEFAULT" bashunit::console_results::print_execution_time return 1 fi printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_SUCCESS" " All tests passed " "$_BASHUNIT_COLOR_DEFAULT" bashunit::console_results::print_execution_time return 0 } function bashunit::console_results::print_execution_time() { if ! bashunit::env::is_total_execution_time_enabled; then return fi local time time=$(bashunit::clock::total_runtime_in_milliseconds) # Strip decimal portion (integer truncation, Bash 3.0 compatible) time="${time%%.*}" time="${time:-0}" if [ "$time" -lt 1000 ]; then printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" \ "Time taken: ${time}ms" return fi local time_in_seconds=$((time / 1000)) if [ "$time_in_seconds" -ge 60 ]; then local minutes=$((time_in_seconds / 60)) local seconds=$((time_in_seconds % 60)) printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" \ "Time taken: ${minutes}m ${seconds}s" return fi local integer_part=$((time / 1000)) local decimal_part=$(( (time % 1000) / 10 )) local formatted_seconds formatted_seconds=$(printf "%d.%02d" "$integer_part" "$decimal_part") printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" \ "Time taken: ${formatted_seconds}s" } function bashunit::console_results::format_duration() { local duration_ms="$1" if [ "$duration_ms" -ge 60000 ]; then local time_in_seconds=$((duration_ms / 1000)) local minutes=$((time_in_seconds / 60)) local seconds=$((time_in_seconds % 60)) echo "${minutes}m ${seconds}s" elif [ "$duration_ms" -ge 1000 ]; then local integer_part=$((duration_ms / 1000)) local decimal_part=$(( (duration_ms % 1000) / 10 )) local formatted_seconds formatted_seconds=$(printf "%d.%02d" "$integer_part" "$decimal_part") echo "${formatted_seconds}s" else echo "${duration_ms}ms" fi } function bashunit::console_results::print_hook_completed() { local hook_name="$1" local duration_ms="$2" if bashunit::env::is_simple_output_enabled; then return fi if bashunit::env::is_failures_only_enabled; then return fi if bashunit::env::is_no_progress_enabled; then return fi if bashunit::env::is_tap_output_enabled; then return fi if bashunit::parallel::is_enabled; then return fi local line line=$(printf "%s● %s%s" \ "$_BASHUNIT_COLOR_PASSED" "$hook_name" "$_BASHUNIT_COLOR_DEFAULT") local time_display time_display=$(bashunit::console_results::format_duration "$duration_ms") printf "%s\n" "$(bashunit::str::rpad "$line" "$time_display")" } function bashunit::console_results::print_successful_test() { local test_name=$1 shift local duration=${1:-"0"} shift # Pure-bash concatenation (the printf only did %s substitution) to avoid a # $(...) fork per passing test (#764). local line if [ -z "$*" ]; then line="${_BASHUNIT_COLOR_PASSED}✓ Passed${_BASHUNIT_COLOR_DEFAULT}: ${test_name}" else local quoted_args="" local arg for arg in "$@"; do if [ -z "$quoted_args" ]; then quoted_args="'$arg'" else quoted_args="$quoted_args, '$arg'" fi done line="${_BASHUNIT_COLOR_PASSED}✓ Passed${_BASHUNIT_COLOR_DEFAULT}: ${test_name} (${quoted_args})" fi # Retry annotation (e.g. " (retry 1/2)") set by the runner when a test only # passed after retrying; empty in the common no-retry path. line="${line}${_BASHUNIT_RETRY_NOTE:-}" local full_line=$line if bashunit::env::is_show_execution_time_enabled; then local time_display if [ "$duration" -ge 60000 ]; then local time_in_seconds=$((duration / 1000)) local minutes=$((time_in_seconds / 60)) local seconds=$((time_in_seconds % 60)) time_display="${minutes}m ${seconds}s" elif [ "$duration" -ge 1000 ]; then local integer_part=$((duration / 1000)) local decimal_part=$(( (duration % 1000) / 10 )) local formatted_seconds formatted_seconds=$(printf "%d.%02d" "$integer_part" "$decimal_part") time_display="${formatted_seconds}s" else time_display="${duration}ms" fi full_line="$(bashunit::str::rpad "$line" "$time_display")" fi bashunit::state::print_line "successful" "$full_line" } ## # Returns a faint " at :" suffix (preceded by a newline) pointing # at the currently running test function, or an empty string when the location # is unknown. Used to append source context to failure output. ## function bashunit::console_results::test_location_suffix() { local location=${_BASHUNIT_TEST_LOCATION:-} if [ -z "$location" ]; then return 0 fi printf "\n ${_BASHUNIT_COLOR_FAINT}at %s${_BASHUNIT_COLOR_DEFAULT}" "$location" } function bashunit::console_results::print_failure_message() { local test_name=$1 local failure_message=$2 local line line="$(printf "\ ${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s ${_BASHUNIT_COLOR_FAINT}Message:${_BASHUNIT_COLOR_DEFAULT} \ ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ "${test_name}" "${failure_message}")" line="$line$(bashunit::console_results::test_location_suffix)" bashunit::state::print_line "failure" "$line" } ## # Renders a git word-diff of two files, indented, and echoes it. Colorized # unless --no-color is active. Empty when git is unavailable or files match. # Shared by the snapshot-failure and multiline assert-failure renderers. # Arguments: $1 expected file path, $2 actual file path ## function bashunit::console_results::render_diff() { local expected_file=$1 local actual_file=$2 if ! bashunit::dependencies::has_git; then return 0 fi local color_flag="--color=always" if bashunit::env::is_no_color_enabled; then color_flag="--color=never" fi # `git diff` exits non-zero when the files differ; the `|| true` keeps that # from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's # header lines; `sed` indents the body. git diff --no-index --word-diff "$color_flag" \ "$expected_file" "$actual_file" 2>/dev/null | tail -n +6 | sed "s/^/ /" || true } ## # Echoes a value's first line, appending an ellipsis when it spans several # lines. Used to keep the inline quoted value on one line when a diff follows. ## function bashunit::console_results::first_line_ellipsis() { local text=$1 local first="${text%%$'\n'*}" if [ "$first" != "$text" ]; then printf '%s…' "$first" else printf '%s' "$text" fi } function bashunit::console_results::print_failed_test() { local function_name=$1 local expected=$2 local failure_condition_message=$3 local actual=$4 local extra_key=${5-} local extra_value=${6-} # For multiline values, render a unified diff below the header (git required, # opt out with BASHUNIT_NO_DIFF). Single-line output stays byte-identical. local show_diff=false case "$expected$actual" in *$'\n'*) if bashunit::env::is_diff_enabled && bashunit::dependencies::has_git; then show_diff=true fi ;; esac local display_expected=$expected local display_actual=$actual if [ "$show_diff" = true ]; then display_expected="$(bashunit::console_results::first_line_ellipsis "$expected")" display_actual="$(bashunit::console_results::first_line_ellipsis "$actual")" fi local line line="$(printf "\ ${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s ${_BASHUNIT_COLOR_FAINT}Expected${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ "${function_name}" "${display_expected}" "${failure_condition_message}" "${display_actual}")" if [ "$show_diff" = true ]; then local _expected_file _actual_file _expected_file="$(bashunit::temp_file diff_expected)" _actual_file="$(bashunit::temp_file diff_actual)" printf '%s\n' "$expected" >"$_expected_file" printf '%s\n' "$actual" >"$_actual_file" line="$line $(bashunit::console_results::render_diff "$_expected_file" "$_actual_file")" rm -f "$_expected_file" "$_actual_file" fi if [ -n "$extra_key" ]; then line="$line$(printf "\ ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ "${extra_key}" "${extra_value}")" fi line="$line$(bashunit::console_results::test_location_suffix)" bashunit::state::print_line "failed" "$line" } function bashunit::console_results::print_failed_snapshot_test() { local function_name=$1 local snapshot_file=$2 local actual_content=${3-} local line line="$(printf "${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s ${_BASHUNIT_COLOR_FAINT}Expected to match the snapshot${_BASHUNIT_COLOR_DEFAULT}\n" "$function_name")" if bashunit::dependencies::has_git; then local actual_file="${snapshot_file}.tmp" echo "$actual_content" >"$actual_file" line="$line$(bashunit::console_results::render_diff "$snapshot_file" "$actual_file")" rm "$actual_file" else line="$line$(bashunit::console_results::snapshot_line_diff \ "$(cat "$snapshot_file")" "$actual_content")" fi bashunit::state::print_line "failed_snapshot" "$line" } ## # Renders a readable line-by-line diff between an expected snapshot and the # actual content, used as a fallback when git is unavailable. Common lines are # shown as context, expected-only lines are prefixed with '-' and actual-only # lines with '+'. Bash 3.0+ compatible (no mapfile, no associative arrays). # Arguments: $1 expected content, $2 actual content ## function bashunit::console_results::snapshot_line_diff() { local expected=$1 local actual=$2 # Explicit empty-array init so referencing the arrays is safe under `set -u` # on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound). # Declare and assign separately: bash 3.0 does not expand a compound array # assignment attached to `local`, it stores the literal "()" as element 0. local expected_lines actual_lines expected_lines=() actual_lines=() local _line="" local i=0 while IFS= read -r _line || [ -n "$_line" ]; do expected_lines[i]=$_line i=$((i + 1)) done </dev/null; then printf '%s' "$value" | base64 | tr -d '\n' else printf '%s' "$value" | openssl enc -base64 -A fi } function bashunit::helper::decode_base64() { local value="$1" # Empty input decodes to empty; short-circuit to skip the base64 fork (#762). if [ -z "$value" ] || [ "$value" = "_BASHUNIT_EMPTY_" ]; then printf '' return fi if command -v base64 >/dev/null; then printf '%s' "$value" | base64 -d else printf '%s' "$value" | openssl enc -d -base64 fi } function bashunit::helper::check_duplicate_functions() { local script="$1" # Handle directory changes in set_up_before_script (issue #529) if [ ! -f "$script" ] && [ -n "${BASHUNIT_WORKING_DIR:-}" ]; then script="$BASHUNIT_WORKING_DIR/$script" fi # One awk pass over the file finds each test-function definition and emits only # the names seen more than once, folding the former grep + awk + sort + uniq # chain into a single awk (#761). The END block insertion-sorts the (tiny, # usually empty) duplicate list itself, so no `sort` fork is needed to keep # the output deterministic. local duplicates duplicates=$(awk ' /^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)[[:space:]]*\{/ { for (i = 1; i <= NF; i++) { if ($i ~ /^test[a-zA-Z_][a-zA-Z0-9_]*\(\)$/) { name = $i gsub(/\(\)/, "", name) if (++seen[name] == 2) { dup[name] = 1 } break } } } END { n = 0 for (name in dup) { names[++n] = name } for (i = 2; i <= n; i++) { v = names[i] j = i - 1 while (j >= 1 && names[j] > v) { names[j + 1] = names[j] j-- } names[j + 1] = v } for (i = 1; i <= n; i++) { print names[i] } } ' "$script") if [ -n "$duplicates" ]; then bashunit::state::set_duplicated_functions_merged "$script" "$duplicates" return 1 fi return 0 } # # @param $1 string Eg: "prefix" # @param $2 string Eg: "filter" # @param $3 array Eg: "[fn1, fn2, prefix_filter_fn3, fn4, ...]" # # @return array Eg: "[prefix_filter_fn3, ...]" The filtered functions with prefix # function bashunit::helper::get_functions_to_run() { local prefix=$1 local filter=${2/test_/} local function_names=$3 local filtered_functions="" local fn for fn in $function_names; do local _fn_match=false case "$fn" in ${prefix}_*${filter}*) _fn_match=true ;; esac if [ "$_fn_match" = true ]; then local _dup=false case "$filtered_functions" in *" $fn"*) _dup=true ;; esac if [ "$_dup" = true ]; then return 1 fi filtered_functions="$filtered_functions $fn" fi done echo "${filtered_functions# }" } # # @param $1 string Eg: "do_something" # function bashunit::helper::execute_function_if_exists() { local fn_name="$1" if declare -F "$fn_name" >/dev/null 2>&1; then "$fn_name" return $? fi return 0 } # # @param $1 string Eg: "do_something" # function bashunit::helper::unset_if_exists() { unset "$1" 2>/dev/null } function bashunit::helper::find_files_recursive() { ## Remove trailing slash using parameter expansion local path="${1%%/}" local pattern="${2:-*[tT]est.sh}" # When the pattern targets *.sh test files, also match the .bash variant. Both # the plain `*test.sh` and the default glob `*[tT]est.sh` end in `.sh`; a case # match on either (no grep fork) is enough to decide. local alt_pattern="" case "$pattern" in *test.sh | *'[tT]est.sh') alt_pattern="${pattern%.sh}.bash" ;; esac local _has_glob=false case "$path" in *"*"*) _has_glob=true ;; esac if [ "$_has_glob" = true ]; then if [ -n "$alt_pattern" ]; then eval "find $path -type f \( -name \"$pattern\" -o -name \"$alt_pattern\" \)" | sort -u else eval "find $path -type f -name \"$pattern\"" | sort -u fi elif [ -d "$path" ]; then if [ -n "$alt_pattern" ]; then find "$path" -type f \( -name "$pattern" -o -name "$alt_pattern" \) | sort -u else find "$path" -type f -name "$pattern" | sort -u fi else echo "$path" fi } _BASHUNIT_HELPER_VARNAME_OUT="" # Return-slot variant of normalize_variable_name: writes the result into # _BASHUNIT_HELPER_VARNAME_OUT with no fork, for hot-path callers that would # otherwise capture it with a command substitution (e.g. snapshot path # resolution, which calls it twice per assertion). function bashunit::helper::normalize_variable_name_to_slot() { local input_string="$1" local normalized_string="${input_string//[^a-zA-Z0-9_]/_}" # First character must be alpha or underscore. Empty string also gets a `_` # prefix to satisfy the same identifier rule. Uses pure-bash globbing to # avoid a per-call grep fork (called once per test via generate_id). case "${normalized_string:0:1}" in [a-zA-Z_]) ;; *) normalized_string="_$normalized_string" ;; esac _BASHUNIT_HELPER_VARNAME_OUT=$normalized_string } function bashunit::helper::normalize_variable_name() { bashunit::helper::normalize_variable_name_to_slot "$1" builtin echo "$_BASHUNIT_HELPER_VARNAME_OUT" } # Provider map for the most recently scanned script. Scanning a file once and # caching the test-function -> provider-function pairs replaces a per-test # grep+sed fork with a pure-bash lookup on the hot path (issue #763). _BASHUNIT_PROVIDER_MAP_SCRIPT="" _BASHUNIT_PROVIDER_MAP_FNS=() _BASHUNIT_PROVIDER_MAP_PROVIDERS=() _BASHUNIT_PROVIDER_FN_OUT="" # Set true when the scanned file carries the "# bashunit: no-parallel-tests" # opt-out; detected in the same awk pass to avoid a per-file grep fork (#774). _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false # # Resolves a script path, applying the issue #529 working-dir fallback. # Writes the resolved path into _BASHUNIT_PROVIDER_RESOLVED_OUT (empty if unreadable). # _BASHUNIT_PROVIDER_RESOLVED_OUT="" function bashunit::helper::_resolve_provider_script() { local script=$1 # Handle directory changes in set_up_before_script (issue #529) if [ ! -f "$script" ] && [ -n "${BASHUNIT_WORKING_DIR:-}" ]; then script="$BASHUNIT_WORKING_DIR/$script" fi if [ ! -f "$script" ]; then _BASHUNIT_PROVIDER_RESOLVED_OUT="" return fi _BASHUNIT_PROVIDER_RESOLVED_OUT=$script } # # Scans a script once and caches its test-function -> provider-function pairs. # Memoized by resolved path, so repeated calls for the same file do not rescan. # # @param $1 string Path to the test script # function bashunit::helper::build_provider_map() { bashunit::helper::_resolve_provider_script "$1" local script=$_BASHUNIT_PROVIDER_RESOLVED_OUT if [ -z "$script" ]; then # Unreadable path: reset to an empty map keyed to this argument so a # follow-up lookup returns empty without rescanning. _BASHUNIT_PROVIDER_MAP_SCRIPT="$1" _BASHUNIT_PROVIDER_MAP_FNS=() _BASHUNIT_PROVIDER_MAP_PROVIDERS=() _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false return fi if [ "$script" = "$_BASHUNIT_PROVIDER_MAP_SCRIPT" ]; then return fi _BASHUNIT_PROVIDER_MAP_SCRIPT="$script" _BASHUNIT_PROVIDER_MAP_FNS=() _BASHUNIT_PROVIDER_MAP_PROVIDERS=() _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false local count=0 local fn provider # Single awk pass emits "\t" for every function whose # definition is at most two lines below a `# @data_provider` (or # `# data_provider`) annotation, mirroring the previous grep -B2 + sed. # A reserved sentinel fn name carries the no-parallel-tests flag out of the # single awk pass; real fn names are identifiers so they never collide. while IFS=$'\t' read -r fn provider; do [ -z "$fn" ] && continue if [ "$fn" = "@@no_parallel@@" ]; then [ "$provider" = "1" ] && _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=true continue fi _BASHUNIT_PROVIDER_MAP_FNS[count]="$fn" _BASHUNIT_PROVIDER_MAP_PROVIDERS[count]="$provider" count=$((count + 1)) done < <(awk ' /^# bashunit: no-parallel-tests/ { no_parallel = 1; next } /^[[:space:]]*#[[:space:]]*@?data_provider[[:space:]]+/ { p = $0 sub(/^[[:space:]]*#[[:space:]]*@?data_provider[[:space:]]+/, "", p) sub(/[[:space:]]+$/, "", p) pending = p pending_line = NR next } { if (pending != "" && NR - pending_line <= 2) { if (match($0, /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/)) { fn = $0 sub(/^[[:space:]]*(function[[:space:]]+)?/, "", fn) sub(/[[:space:]]*\(\).*/, "", fn) printf "%s\t%s\n", fn, pending pending = "" } } else if (pending != "" && NR - pending_line > 2) { pending = "" } } END { printf "@@no_parallel@@\t%d\n", no_parallel } ' "$script" 2>/dev/null) } # # Pure-bash lookup against the cached provider map. # Writes the provider-function name (or empty) into _BASHUNIT_PROVIDER_FN_OUT. # # @param $1 string Test-function name # function bashunit::helper::provider_for_function() { local function_name=$1 local i=0 local total=${#_BASHUNIT_PROVIDER_MAP_FNS[@]} while [ "$i" -lt "$total" ]; do if [ "${_BASHUNIT_PROVIDER_MAP_FNS[i]}" = "$function_name" ]; then _BASHUNIT_PROVIDER_FN_OUT="${_BASHUNIT_PROVIDER_MAP_PROVIDERS[i]}" return fi i=$((i + 1)) done _BASHUNIT_PROVIDER_FN_OUT="" } function bashunit::helper::get_provider_data() { local function_name="$1" local script="$2" bashunit::helper::build_provider_map "$script" bashunit::helper::provider_for_function "$function_name" if [ -n "$_BASHUNIT_PROVIDER_FN_OUT" ]; then bashunit::helper::execute_function_if_exists "$_BASHUNIT_PROVIDER_FN_OUT" fi } function bashunit::helper::trim() { local input_string="$1" local trimmed_string trimmed_string="${input_string#"${input_string%%[![:space:]]*}"}" trimmed_string="${trimmed_string%"${trimmed_string##*[![:space:]]}"}" echo "$trimmed_string" } function bashunit::helper::get_latest_tag() { if ! bashunit::dependencies::has_git; then return 1 fi # Floating major tags (e.g. v0) are not releases and must not win git ls-remote --tags "$BASHUNIT_GIT_REPO" | awk '{print $2}' | sed 's|^refs/tags/||' | grep -v '\^{}' | grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)?$' | sort -Vr | head -n 1 } # Also written by find_total_tests so a main-shell caller can read the count # without a $() capture (which would discard the provider-map cache built here). _BASHUNIT_HELPER_TOTAL_TESTS_OUT=0 function bashunit::helper::find_total_tests() { local filter=${1:-} shift || true _BASHUNIT_HELPER_TOTAL_TESTS_OUT=0 if [ $# -eq 0 ]; then echo 0 return fi local total_count=0 local file for file in "$@"; do if [ ! -f "$file" ]; then continue fi # Build the provider map in THIS shell before the counting subshell: the # subshell inherits it (its own build call becomes a cache hit), and when # the caller runs in the main shell the runner's later build for the same # file is a cache hit too — one awk scan per file instead of two. bashunit::helper::build_provider_map "$file" local file_count file_count=$( ( # shellcheck source=/dev/null source "$file" local all_fn_names all_fn_names=$(compgen -A function) local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$all_fn_names") || true local count=0 local IFS=$' \t\n' if [ -n "$filtered_functions" ]; then local -a functions_to_run=() # shellcheck disable=SC2206 functions_to_run=($filtered_functions) # shellcheck disable=SC2034 local -a provider_data=() local provider_data_count=0 local fn_name line # Scan once; functions without a provider count as 1 with no fork (#763). bashunit::helper::build_provider_map "$file" for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do bashunit::helper::provider_for_function "$fn_name" if [ -z "$_BASHUNIT_PROVIDER_FN_OUT" ]; then count=$((count + 1)) continue fi provider_data_count=0 while IFS=" " read -r line; do [ -z "$line" ] && continue provider_data_count=$((provider_data_count + 1)) done <<<"$(bashunit::helper::execute_function_if_exists "$_BASHUNIT_PROVIDER_FN_OUT")" if [ "$provider_data_count" -eq 0 ]; then count=$((count + 1)) else count=$((count + provider_data_count)) fi done fi echo "$count" )) total_count=$((total_count + file_count)) done _BASHUNIT_HELPER_TOTAL_TESTS_OUT=$total_count echo "$total_count" } function bashunit::helper::load_test_files() { local filter="${1:-}" shift || true # Bash 3.0 compatible: use $# after shift to check for files local has_files=$# if [ "$has_files" -eq 0 ]; then if [ -n "${BASHUNIT_DEFAULT_PATH:-}" ]; then bashunit::helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH" fi else printf "%s\n" "$@" fi } function bashunit::helper::load_bench_files() { local filter="${1:-}" shift || true # Bash 3.0 compatible: use $# after shift to check for files local has_files=$# if [ "$has_files" -eq 0 ]; then if [ -n "${BASHUNIT_DEFAULT_PATH:-}" ]; then bashunit::helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH" '*[bB]ench.sh' fi else printf "%s\n" "$@" fi } # # @param $1 string function name # @return number line number of the function in the source file # function bashunit::helper::get_function_line_number() { local fn_name=$1 # Enable extdebug only inside the subshell so the caller's setting is not # clobbered. With extdebug, `declare -F` prints " "; parse # the line number with shell word-splitting instead of forking awk. local declaration declaration=$( shopt -s extdebug declare -F "$fn_name" ) declaration="${declaration#* }" echo "${declaration%% *}" } # Writes a sanitized, process-unique id into _BASHUNIT_HELPER_ID_OUT. # Return-slot form so the per-test caller avoids a $(...) capture fork (#764). # Arguments: $1 basename _BASHUNIT_HELPER_ID_OUT="" function bashunit::helper::generate_id() { local basename="$1" # Inline normalize_variable_name + random_str to avoid two forks per call. # generate_id is called once per test and per file load. local sanitized="${basename//[^a-zA-Z0-9_]/_}" case "${sanitized:0:1}" in [a-zA-Z_]) ;; *) sanitized="_$sanitized" ;; esac if bashunit::env::is_parallel_run_enabled; then local _chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' local _suffix='' local _i for ((_i = 0; _i < 6; _i++)); do _suffix="$_suffix${_chars:RANDOM%${#_chars}:1}" done _BASHUNIT_HELPER_ID_OUT="${sanitized}_$$_${_suffix}" else _BASHUNIT_HELPER_ID_OUT="${sanitized}_$$" fi } # # Parses a file path that may contain a filter suffix. # Supports two syntaxes: # - path::function_name (filter by function name) # - path:line_number (filter by line number) # # @param $1 string Eg: "tests/test.sh::test_foo" or "tests/test.sh:123" # # @return string Two lines: first is file path, second is filter (or empty) # function bashunit::helper::parse_file_path_filter() { local input="$1" local file_path="" local filter="" # Check for :: syntax (function name filter) case "$input" in *"::"*) file_path="${input%%::*}" filter="${input#*::}" ;; *) # Check for :number syntax (line number filter): a non-empty path, a # colon, then digits to the end of string. Pure-bash parameter expansion # avoids forking grep+sed. local line_number="${input##*:}" local maybe_path="${input%:*}" case "$line_number" in '' | *[!0-9]*) file_path="$input" ;; *) if [ -n "$maybe_path" ] && [ "$maybe_path" != "$input" ]; then # Line number will be resolved to function name later file_path="$maybe_path" filter="__line__:${line_number}" else file_path="$input" fi ;; esac ;; esac echo "$file_path" echo "$filter" } # # Finds the test function that contains a given line number in a file. # # @param $1 string File path # @param $2 number Line number # # @return string The function name, or empty if not found # function bashunit::helper::find_function_at_line() { local file="$1" local target_line="$2" if [ ! -f "$file" ]; then return 1 fi # Find all test function definitions and their line numbers local best_match="" local best_line=0 local line_num content while IFS=: read -r line_num content; do # Extract function name from the line local fn_name="" local fn_pattern='^[[:space:]]*(function[[:space:]]+)?(test[a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\(\).*' fn_name=$(echo "$content" | sed -nE "s/$fn_pattern/\2/p") if [ -n "$fn_name" ] && [ "$line_num" -le "$target_line" ] && [ "$line_num" -gt "$best_line" ]; then best_match="$fn_name" best_line="$line_num" fi done < <(grep -n -E '^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)' "$file") echo "$best_match" } # Tags map for the most recently scanned script. Mirrors the provider map # (build_provider_map): scanning a file once and caching each test-function -> # comma-separated tags pair replaces a per-test grep/sed backward walk with a # pure-bash lookup on the hot path when `--tag`/`--exclude-tag` is used (#773). _BASHUNIT_TAGS_MAP_SCRIPT="" _BASHUNIT_TAGS_MAP_FNS=() _BASHUNIT_TAGS_MAP_TAGS=() _BASHUNIT_TAGS_OUT="" # # Scans a script once and caches its test-function -> tags pairs. # Memoized by resolved path, so repeated calls for the same file do not rescan. # # @param $1 string Path to the test script # function bashunit::helper::build_tags_map() { local script=$1 # Handle directory changes in set_up_before_script (issue #529) if [ ! -f "$script" ] && [ -n "${BASHUNIT_WORKING_DIR:-}" ]; then script="$BASHUNIT_WORKING_DIR/$script" fi if [ ! -f "$script" ]; then # Unreadable path: reset to an empty map keyed to this argument so a # follow-up lookup returns empty without rescanning. _BASHUNIT_TAGS_MAP_SCRIPT="$1" _BASHUNIT_TAGS_MAP_FNS=() _BASHUNIT_TAGS_MAP_TAGS=() return fi if [ "$script" = "$_BASHUNIT_TAGS_MAP_SCRIPT" ]; then return fi _BASHUNIT_TAGS_MAP_SCRIPT="$script" _BASHUNIT_TAGS_MAP_FNS=() _BASHUNIT_TAGS_MAP_TAGS=() local count=0 local fn tags # Single awk pass emits "\t" for every function that carries at # least one `# @tag ` comment in the contiguous comment block directly # above its definition, mirroring the previous per-function backward walk. # Tags accumulate nearest-to-the-function first (same order the old walk # produced). A blank or non-comment line breaks the association; other # comment lines keep the block open. Both `function test_x` and `test_x()` # definition styles are recognised. while IFS=$'\t' read -r fn tags; do [ -z "$fn" ] && continue _BASHUNIT_TAGS_MAP_FNS[count]="$fn" _BASHUNIT_TAGS_MAP_TAGS[count]="$tags" count=$((count + 1)) done < <(awk ' /^[[:space:]]*#[[:space:]]*@tag[[:space:]]/ { t = $0 sub(/^[[:space:]]*#[[:space:]]*@tag[[:space:]]+/, "", t) tags = (tags == "" ? t : t "," tags) next } /^[[:space:]]*#/ { next } /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/ { fn = $0 sub(/^[[:space:]]*(function[[:space:]]+)?/, "", fn) sub(/[[:space:]]*\(\).*/, "", fn) if (tags != "") printf "%s\t%s\n", fn, tags tags = "" next } { tags = "" } ' "$script" 2>/dev/null) } # # Pure-bash lookup against the cached tags map. # Writes the comma-separated tags (or empty) into _BASHUNIT_TAGS_OUT. # # @param $1 string Test-function name # function bashunit::helper::tags_for_function() { local function_name=$1 local i=0 local total=${#_BASHUNIT_TAGS_MAP_FNS[@]} while [ "$i" -lt "$total" ]; do if [ "${_BASHUNIT_TAGS_MAP_FNS[i]}" = "$function_name" ]; then _BASHUNIT_TAGS_OUT="${_BASHUNIT_TAGS_MAP_TAGS[i]}" return fi i=$((i + 1)) done _BASHUNIT_TAGS_OUT="" } # # Extracts @tag annotations for a specific function from a test file. # Thin wrapper over the cached tags map, kept for callers that want the tags # on stdout. Hot-path call sites use build_tags_map + tags_for_function to # avoid the subshell fork. # # @param $1 string Function name # @param $2 string Script file path # # @return string Comma-separated list of tags, or empty if none # function bashunit::helper::get_tags_for_function() { bashunit::helper::build_tags_map "$2" bashunit::helper::tags_for_function "$1" echo "$_BASHUNIT_TAGS_OUT" } # # Checks if a function's tags match the include/exclude filters. # Include uses OR logic (any match passes). # Exclude uses OR logic (any match fails). # Exclude takes precedence over include. # # @param $1 string Comma-separated tags for the function # @param $2 string Comma-separated include tags (empty = no filter) # @param $3 string Comma-separated exclude tags (empty = no filter) # # @return 0 if function should run, 1 if it should be skipped # function bashunit::helper::function_matches_tags() { local fn_tags="$1" local include_tags="$2" local exclude_tags="$3" # Check exclude tags first (exclude wins over include) if [ -n "$exclude_tags" ]; then local IFS=',' local etag for etag in $exclude_tags; do local check_tag for check_tag in $fn_tags; do if [ "$check_tag" = "$etag" ]; then return 1 fi done done fi # Check include tags (OR logic: any match passes) if [ -n "$include_tags" ]; then if [ -z "$fn_tags" ]; then return 1 fi local IFS=',' local itag for itag in $include_tags; do local check_tag for check_tag in $fn_tags; do if [ "$check_tag" = "$itag" ]; then return 0 fi done done return 1 fi return 0 } # test_title.sh function bashunit::set_test_title() { bashunit::state::set_test_title "$1" } # upgrade.sh function bashunit::upgrade::upgrade() { local install_dir="${BASHUNIT_INSTALL_DIR:-}" if [ -z "$install_dir" ]; then install_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fi local target="$install_dir/bashunit" local latest_tag latest_tag="$(bashunit::helper::get_latest_tag)" if [ -z "$latest_tag" ]; then echo "Failed to resolve latest bashunit version. Check your internet connection and that 'git' is installed." >&2 return 1 fi if [ "$BASHUNIT_VERSION" = "$latest_tag" ]; then echo "> You are already on latest version" return 0 fi echo "> Upgrading bashunit to latest version" local url="https://github.com/TypedDevs/bashunit/releases/download/$latest_tag/bashunit" local err_file err_file="$(mktemp 2>/dev/null || echo "/tmp/bashunit_upgrade_err.$$")" local download_status=0 bashunit::io::download_to "$url" "$target" 2>"$err_file" || download_status=$? if [ "$download_status" -ne 0 ]; then echo "Failed to download bashunit $latest_tag from $url" >&2 if [ -s "$err_file" ]; then echo "Reason:" >&2 sed 's/^/ /' "$err_file" >&2 fi rm -f "$err_file" "$target" return 1 fi rm -f "$err_file" if [ ! -s "$target" ]; then echo "Failed to download bashunit $latest_tag from $url (empty file)" >&2 rm -f "$target" return 1 fi if ! chmod u+x "$target"; then echo "Failed to make $target executable" >&2 return 1 fi echo "> bashunit upgraded successfully to latest version $latest_tag" } # watch.sh # bashunit watch mode # Watches test and source files for changes and re-runs tests automatically. # Requires: inotifywait (inotify-tools) on Linux, or fswatch on macOS. function bashunit::watch::_command_exists() { command -v "$1" &>/dev/null } function bashunit::watch::is_available() { if bashunit::watch::_command_exists inotifywait; then echo "inotifywait" elif bashunit::watch::_command_exists fswatch; then echo "fswatch" else echo "polling" fi } function bashunit::watch::run() { local path="${1:-.}" shift # Declare and assign separately: bash 3.0 does not expand a compound array # assignment attached to `local`, it collapses "$@" into one literal element. local extra_args extra_args=("$@") local tool tool=$(bashunit::watch::is_available) if [ "$tool" = "polling" ]; then bashunit::watch::_print_polling_notice "$path" else printf "%sbashunit --watch%s watching: %s\n\n" \ "${_BASHUNIT_COLOR_PASSED}" "${_BASHUNIT_COLOR_DEFAULT}" "$path" fi # Run once immediately before entering the watch loop bashunit::watch::run_tests "$path" "${extra_args[@]+"${extra_args[@]}"}" while true; do bashunit::watch::wait_for_change "$tool" "$path" printf "\n%s[change detected — re-running tests]%s\n\n" \ "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" bashunit::watch::run_tests "$path" "${extra_args[@]+"${extra_args[@]}"}" done } function bashunit::watch::run_tests() { local path="$1" shift # Re-invoke bashunit test in a subshell so state resets cleanly each run "$BASHUNIT_ROOT_DIR/bashunit" test "$path" "$@" return $? } function bashunit::watch::_print_polling_notice() { local path="$1" printf "%sbashunit --watch%s polling: %s (every %ss)\n\n" \ "${_BASHUNIT_COLOR_PASSED}" "${_BASHUNIT_COLOR_DEFAULT}" \ "$path" "${BASHUNIT_WATCH_INTERVAL:-2}" printf " No 'inotifywait' or 'fswatch' found; using pure-shell polling.\n" printf " Install one for instant triggers:\n" printf " Linux: sudo apt install inotify-tools\n" printf " macOS: brew install fswatch\n\n" } # Lists watched *.sh files modified since the sentinel file was touched. # Non-empty output means a rerun is due. `find -newer` is POSIX and avoids the # GNU/BSD `stat` flag divergence. function bashunit::watch::_poll_changes() { local sentinel="$1" local path="$2" find "$path" -name '*.sh' -newer "$sentinel" -print 2>/dev/null } function bashunit::watch::wait_for_change() { local tool="$1" local path="$2" case "$tool" in polling) local sentinel sentinel="$(bashunit::temp_dir watch)/sentinel" while true; do : >"$sentinel" sleep "${BASHUNIT_WATCH_INTERVAL:-2}" if [ -n "$(bashunit::watch::_poll_changes "$sentinel" "$path")" ]; then return 0 fi done ;; inotifywait) inotifywait \ --quiet \ --recursive \ --event modify,create,delete,move \ --include '.*\.sh$' \ "$path" 2>/dev/null ;; fswatch) # fswatch outputs one line per event; we only need the first one fswatch \ --recursive \ --include='.*\.sh$' \ --exclude='.*' \ --one-event \ "$path" 2>/dev/null ;; esac } # assertions.sh # assert.sh # Helper to mark assertion as failed and set the guard flag function bashunit::assert::mark_failed() { bashunit::state::add_assertions_failed bashunit::state::mark_assertion_failed_in_test } # Guard clause to skip assertion if one already failed in test (when stop-on-assertion is enabled) function bashunit::assert::should_skip() { bashunit::env::is_stop_on_assertion_failure_enabled && ((_BASHUNIT_ASSERTION_FAILED_IN_TEST)) } _BASHUNIT_ASSERT_LABEL_OUT="" # Resolve assertion label into the slot _BASHUNIT_ASSERT_LABEL_OUT with no fork: # use custom label if provided, otherwise derive from the test function name. # Must be called at the same stack depth as the echoing wrapper so the test-frame # fallback keeps resolving against the caller of the assertion. function bashunit::assert::label_to_slot() { local custom_label="${1:-}" if [ -n "$custom_label" ]; then _BASHUNIT_ASSERT_LABEL_OUT=$custom_label return fi bashunit::helper::find_test_function_name_to_slot bashunit::helper::normalize_test_function_name_to_slot "$_BASHUNIT_HELPER_TESTFN_OUT" _BASHUNIT_ASSERT_LABEL_OUT=$_BASHUNIT_HELPER_NORMALIZED_OUT } # Resolve assertion label: use custom label if provided, otherwise derive from test function name function bashunit::assert::label() { bashunit::assert::label_to_slot "${1:-}" builtin echo "$_BASHUNIT_ASSERT_LABEL_OUT" } function bashunit::fail() { bashunit::assert::should_skip && return 0 local message="${1:-${FUNCNAME[1]}}" bashunit::helper::find_test_function_name_to_slot bashunit::helper::normalize_test_function_name_to_slot "$_BASHUNIT_HELPER_TESTFN_OUT" local label=$_BASHUNIT_HELPER_NORMALIZED_OUT bashunit::assert::mark_failed bashunit::console_results::print_failure_message "${label}" "$message" } function assert_true() { bashunit::assert::should_skip && return 0 local actual="$1" # Check for expected literal values first case "$actual" in "") bashunit::handle_bool_assertion_failure "true or 0" "$actual" return ;; "true" | "0") bashunit::state::add_assertions_passed return ;; "false" | "1") bashunit::handle_bool_assertion_failure "true or 0" "$actual" return ;; esac # Run command or eval and check the exit code bashunit::run_command_or_eval "$actual" local exit_code=$? if [ "$exit_code" -ne 0 ]; then bashunit::handle_bool_assertion_failure "command or function with zero exit code" "exit code: $exit_code" else bashunit::state::add_assertions_passed fi } function assert_false() { bashunit::assert::should_skip && return 0 local actual="$1" # Check for expected literal values first case "$actual" in "") bashunit::handle_bool_assertion_failure "false or 1" "$actual" return ;; "false" | "1") bashunit::state::add_assertions_passed return ;; "true" | "0") bashunit::handle_bool_assertion_failure "false or 1" "$actual" return ;; esac # Run command or eval and check the exit code bashunit::run_command_or_eval "$actual" local exit_code=$? if [ "$exit_code" -eq 0 ]; then bashunit::handle_bool_assertion_failure "command or function with non-zero exit code" "exit code: $exit_code" else bashunit::state::add_assertions_passed fi } function bashunit::run_command_or_eval() { local cmd="$1" case "$cmd" in eval\ * | eval) eval "${cmd#eval }" &>/dev/null ;; *[=[:space:]]* | "") # An alias name never contains "=" or whitespace, so this can't be an alias # invocation: run it directly. Guarding here also stops `alias -- "$cmd"` # below from *defining* an alias as a side effect when "$cmd" looks like # "name=value" (which would wrongly succeed). "$cmd" &>/dev/null ;; *) # Detect aliases with the `alias` builtin instead of forking # `command -v | grep`: it exits 0 only for a defined alias, matching the # old `^alias` check for functions/binaries/unknown commands (all non-zero). if alias -- "$cmd" >/dev/null 2>&1; then eval "$cmd" &>/dev/null else "$cmd" &>/dev/null fi ;; esac return $? } function bashunit::handle_bool_assertion_failure() { local expected="$1" local got="$2" bashunit::helper::find_test_function_name_to_slot bashunit::helper::normalize_test_function_name_to_slot "$_BASHUNIT_HELPER_TESTFN_OUT" local label=$_BASHUNIT_HELPER_NORMALIZED_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "$label" "$expected" "but got " "$got" } function assert_same() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" if [ "$expected" != "$actual" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${actual}" return fi bashunit::state::add_assertions_passed } function assert_equals() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" bashunit::str::strip_ansi_to_slot "$actual" local actual_cleaned=$_BASHUNIT_STR_STRIPPED_OUT bashunit::str::strip_ansi_to_slot "$expected" local expected_cleaned=$_BASHUNIT_STR_STRIPPED_OUT if [ "$expected_cleaned" != "$actual_cleaned" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected_cleaned}" "but got " "${actual_cleaned}" return fi bashunit::state::add_assertions_passed } function assert_not_equals() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" bashunit::str::strip_ansi_to_slot "$actual" local actual_cleaned=$_BASHUNIT_STR_STRIPPED_OUT bashunit::str::strip_ansi_to_slot "$expected" local expected_cleaned=$_BASHUNIT_STR_STRIPPED_OUT if [ "$expected_cleaned" = "$actual_cleaned" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected_cleaned}" "to not be" "${actual_cleaned}" return fi bashunit::state::add_assertions_passed } function assert_empty() { bashunit::assert::should_skip && return 0 local expected="$1" local label_override="${2:-}" if [ "$expected" != "" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "to be empty" "but got " "${expected}" return fi bashunit::state::add_assertions_passed } function assert_not_empty() { bashunit::assert::should_skip && return 0 local expected="$1" local label_override="${2:-}" if [ "$expected" = "" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "to not be empty" "but got " "${expected}" return fi bashunit::state::add_assertions_passed } function assert_not_same() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" if [ "$expected" = "$actual" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to not be" "${actual}" return fi bashunit::state::add_assertions_passed } function assert_contains() { bashunit::assert::should_skip && return 0 local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") local label_override="" local actual actual=$(printf '%s\n' "${actual_arr[@]}") case "$actual" in *"$expected"*) ;; *) bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_contains_ignore_case() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" # Bash 3.0 compatible: use tr for case-insensitive comparison # (shopt nocasematch was introduced in Bash 3.1) local expected_lower local actual_lower expected_lower=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]') actual_lower=$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]') case "$actual_lower" in *"$expected_lower"*) ;; *) bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_not_contains() { local label_override="" bashunit::assert::should_skip && return 0 local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") case "$actual" in *"$expected"*) bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to not contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_matches() { bashunit::assert::should_skip && return 0 local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") if [ "$(printf '%s' "$actual" | "$GREP" -cE "$expected" || true)" -eq 0 ]; then # Retry with newlines collapsed for cross-line patterns if [ "$(printf '%s' "$actual" | tr '\n' ' ' | "$GREP" -cE "$expected" || true)" -eq 0 ]; then bashunit::helper::find_test_function_name_to_slot bashunit::helper::normalize_test_function_name_to_slot "$_BASHUNIT_HELPER_TESTFN_OUT" local label=$_BASHUNIT_HELPER_NORMALIZED_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to match" "${expected}" return fi fi bashunit::state::add_assertions_passed } function assert_not_matches() { local label_override="" bashunit::assert::should_skip && return 0 local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") # Check both line-by-line and with newlines collapsed for cross-line patterns if [ "$(printf '%s' "$actual" | "$GREP" -cE "$expected" || true)" -gt 0 ] || [ "$(printf '%s' "$actual" | tr '\n' ' ' | "$GREP" -cE "$expected" || true)" -gt 0 ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to not match" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_exec() { bashunit::assert::should_skip && return 0 local label_override="" local cmd="$1" shift local expected_exit=0 local expected_stdout="" local expected_stderr="" local stdout_needle="" local stdout_no_needle="" local stderr_needle="" local stderr_no_needle="" local stdin_input="" local check_stdout=false local check_stderr=false local check_stdout_contains=false local check_stdout_not_contains=false local check_stderr_contains=false local check_stderr_not_contains=false local check_stdin=false while [ $# -gt 0 ]; do case "$1" in --exit) expected_exit="$2" shift 2 ;; --stdout) expected_stdout="$2" check_stdout=true shift 2 ;; --stderr) expected_stderr="$2" check_stderr=true shift 2 ;; --stdout-contains) stdout_needle="$2" check_stdout_contains=true shift 2 ;; --stdout-not-contains) stdout_no_needle="$2" check_stdout_not_contains=true shift 2 ;; --stderr-contains) stderr_needle="$2" check_stderr_contains=true shift 2 ;; --stderr-not-contains) stderr_no_needle="$2" check_stderr_not_contains=true shift 2 ;; --stdin) stdin_input="$2" check_stdin=true shift 2 ;; *) shift ;; esac done local stdout_file stderr_file stdout_file=$("$MKTEMP") stderr_file=$("$MKTEMP") if $check_stdin; then local stdin_file stdin_file=$("$MKTEMP") printf '%s' "$stdin_input" >"$stdin_file" eval "$cmd" <"$stdin_file" >"$stdout_file" 2>"$stderr_file" local exit_code=$? rm -f "$stdin_file" else eval "$cmd" >"$stdout_file" 2>"$stderr_file" local exit_code=$? fi local stdout stdout=$(cat "$stdout_file") local stderr stderr=$(cat "$stderr_file") rm -f "$stdout_file" "$stderr_file" local expected_desc="exit: $expected_exit" local actual_desc="exit: $exit_code" local failed=0 if [ "$exit_code" -ne "$expected_exit" ]; then failed=1 fi if $check_stdout; then expected_desc="$expected_desc"$'\n'"stdout: $expected_stdout" actual_desc="$actual_desc"$'\n'"stdout: $stdout" if [ "$stdout" != "$expected_stdout" ]; then failed=1 fi fi if $check_stdout_contains; then expected_desc="$expected_desc"$'\n'"stdout contains: $stdout_needle" actual_desc="$actual_desc"$'\n'"stdout: $stdout" case "$stdout" in *"$stdout_needle"*) ;; *) failed=1 ;; esac fi if $check_stdout_not_contains; then expected_desc="$expected_desc"$'\n'"stdout not contains: $stdout_no_needle" actual_desc="$actual_desc"$'\n'"stdout: $stdout" case "$stdout" in *"$stdout_no_needle"*) failed=1 ;; esac fi if $check_stderr; then expected_desc="$expected_desc"$'\n'"stderr: $expected_stderr" actual_desc="$actual_desc"$'\n'"stderr: $stderr" if [ "$stderr" != "$expected_stderr" ]; then failed=1 fi fi if $check_stderr_contains; then expected_desc="$expected_desc"$'\n'"stderr contains: $stderr_needle" actual_desc="$actual_desc"$'\n'"stderr: $stderr" case "$stderr" in *"$stderr_needle"*) ;; *) failed=1 ;; esac fi if $check_stderr_not_contains; then expected_desc="$expected_desc"$'\n'"stderr not contains: $stderr_no_needle" actual_desc="$actual_desc"$'\n'"stderr: $stderr" case "$stderr" in *"$stderr_no_needle"*) failed=1 ;; esac fi if [ "$failed" -eq 1 ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "$label" "$expected_desc" "but got " "$actual_desc" return fi bashunit::state::add_assertions_passed } function assert_exit_code() { local actual_exit_code=${3-"$?"} # Capture $? before guard check local label_override="" bashunit::assert::should_skip && return 0 local expected_exit_code="$1" if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual_exit_code}" "to be" "${expected_exit_code}" return fi bashunit::state::add_assertions_passed } function assert_successful_code() { local actual_exit_code=${3-"$?"} # Capture $? before guard check local label_override="" bashunit::assert::should_skip && return 0 local expected_exit_code=0 if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi bashunit::state::add_assertions_passed } function assert_unsuccessful_code() { local actual_exit_code=${3-"$?"} # Capture $? before guard check local label_override="" bashunit::assert::should_skip && return 0 if [ "$actual_exit_code" -eq 0 ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual_exit_code}" "to be non-zero" "but was 0" return fi bashunit::state::add_assertions_passed } function assert_general_error() { local actual_exit_code=${3-"$?"} # Capture $? before guard check local label_override="" bashunit::assert::should_skip && return 0 local expected_exit_code=1 if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi bashunit::state::add_assertions_passed } function assert_command_not_found() { local actual_exit_code=${3-"$?"} # Capture $? before guard check local label_override="" bashunit::assert::should_skip && return 0 local expected_exit_code=127 if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi bashunit::state::add_assertions_passed } function assert_string_starts_with() { local label_override="" bashunit::assert::should_skip && return 0 local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") case "$actual" in "$expected"*) ;; *) bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to start with" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_string_not_starts_with() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" case "$actual" in "$expected"*) bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to not start with" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_string_ends_with() { local label_override="" bashunit::assert::should_skip && return 0 local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") case "$actual" in *"$expected") ;; *) bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to end with" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_string_not_ends_with() { local label_override="" bashunit::assert::should_skip && return 0 local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") local actual actual=$(printf '%s\n' "${actual_arr[@]}") case "$actual" in *"$expected") bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to not end with" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_less_than() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" if ! [ "$actual" -lt "$expected" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to be less than" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_less_or_equal_than() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" if ! [ "$actual" -le "$expected" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to be less or equal than" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_greater_than() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" if ! [ "$actual" -gt "$expected" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to be greater than" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_greater_or_equal_than() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local label_override="${3:-}" if ! [ "$actual" -ge "$expected" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to be greater or equal than" "${expected}" return fi bashunit::state::add_assertions_passed } ## # Whether a value looks like a number (integer or decimal, optional sign). # Returns: 0 when numeric, 1 otherwise. ## function bashunit::assert::_is_numeric() { local value="$1" case "$value" in '' | *[!0-9.+-]*) return 1 ;; esac # Must contain at least one digit (rejects ".", "-", "+"). case "$value" in *[0-9]*) return 0 ;; esac return 1 } ## # Asserts the actual value is within +/- delta of the expected value: # |actual - expected| <= delta. Supports floats via bashunit::math::calculate. # Arguments: $1 - expected, $2 - actual, $3 - delta ## function assert_within_delta() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" local delta="$3" if ! bashunit::assert::_is_numeric "$expected" || ! bashunit::assert::_is_numeric "$actual" || ! bashunit::assert::_is_numeric "$delta"; then bashunit::assert::label_to_slot bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "${_BASHUNIT_ASSERT_LABEL_OUT}" "${expected} ${actual} ${delta}" \ "to all be numeric" "but got a non-numeric value" return fi local diff diff="$(bashunit::math::calculate "$expected - $actual")" case "$diff" in -*) diff="${diff#-}" ;; esac if [ "$(bashunit::math::calculate "$diff <= $delta")" != "1" ]; then bashunit::assert::label_to_slot bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "${_BASHUNIT_ASSERT_LABEL_OUT}" "${actual}" "to be within ${delta} of" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_line_count() { bashunit::assert::should_skip && return 0 local IFS=$' \t\n' local expected="$1" local -a input_arr input_arr=("${@:2}") local label_override="" local input_str input_str=$(printf '%s\n' ${input_arr+"${input_arr[@]}"}) if [ -z "$input_str" ]; then local actual=0 else # Count lines without forking: one line plus each real newline, plus each # literal "\n" (backslash-n) escape, which counts as an extra line break. local actual=1 local _rest="$input_str" while [ "$_rest" != "${_rest#*$'\n'}" ]; do _rest="${_rest#*$'\n'}" actual=$((actual + 1)) done _rest="$input_str" while [ "$_rest" != "${_rest#*\\n}" ]; do _rest="${_rest#*\\n}" actual=$((actual + 1)) done fi if [ "$expected" != "$actual" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${input_str}" \ "to contain number of lines equal to" "${expected}" \ "but found" "${actual}" return fi bashunit::state::add_assertions_passed } function bashunit::format_to_regex() { local format="$1" local regex="" local i=0 local len=${#format} while [ $i -lt "$len" ]; do local char="${format:$i:1}" if [ "$char" = "%" ] && [ $((i + 1)) -lt "$len" ]; then local next="${format:$((i + 1)):1}" case "$next" in d) regex="${regex}[0-9]+" ;; i) regex="${regex}[+-]?[0-9]+" ;; f) regex="${regex}[+-]?[0-9]*\\.?[0-9]+" ;; s) regex="${regex}[^ ]+" ;; x) regex="${regex}[0-9a-fA-F]+" ;; e) regex="${regex}[+-]?[0-9]*\\.?[0-9]+[eE][+-]?[0-9]+" ;; %) regex="${regex}%" ;; *) regex="${regex}%${next}" ;; esac i=$((i + 2)) else case "$char" in . | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$') regex="${regex}\\${char}" ;; \\) regex="${regex}\\\\" ;; *) regex="${regex}${char}" ;; esac i=$((i + 1)) fi done printf '%s' "^${regex}$" } function assert_string_matches_format() { bashunit::assert::should_skip && return 0 local format="$1" local actual="$2" local label_override="${3:-}" local regex regex="$(bashunit::format_to_regex "$format")" if [ "$(printf '%s' "$actual" | "$GREP" -cE "$regex" || true)" -eq 0 ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to match format" "${format}" return fi bashunit::state::add_assertions_passed } function assert_string_not_matches_format() { bashunit::assert::should_skip && return 0 local format="$1" local actual="$2" local label_override="${3:-}" local regex regex="$(bashunit::format_to_regex "$format")" if [ "$(printf '%s' "$actual" | "$GREP" -cE "$regex" || true)" -gt 0 ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to not match format" "${format}" return fi bashunit::state::add_assertions_passed } # assert_arrays.sh function assert_arrays_equal() { bashunit::assert::should_skip && return 0 local label label="$(bashunit::assert::label)" local -a expected_values=() local -a actual_values=() local found_separator=false local argument for argument in "$@"; do if [ "$found_separator" = false ] && [ "$argument" = "--" ]; then found_separator=true continue fi if [ "$found_separator" = true ]; then actual_values[${#actual_values[@]}]="$argument" else expected_values[${#expected_values[@]}]="$argument" fi done if [ "$found_separator" = false ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "$label" "--" "but got " "missing array separator" return fi if [ "${#expected_values[@]}" -ne "${#actual_values[@]}" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "$label" "${expected_values[*]}" "but got " "${actual_values[*]}" \ "Expected length" "${#expected_values[@]}, actual length ${#actual_values[@]}" return fi local index for ((index = 0; index < ${#expected_values[@]}; index++)); do if [ "${expected_values[$index]}" != "${actual_values[$index]}" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "$label" "${expected_values[*]}" "but got " "${actual_values[*]}" \ "Different index" "$index" return fi done bashunit::state::add_assertions_passed } function assert_array_contains() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" shift local -a actual actual=("$@") case "${actual[*]:-}" in *"$expected"*) ;; *) bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_array_length() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" shift # Use $# / $* rather than building an array: on Bash 3.0 under `set -u`, # expanding "$@" into an array with zero elements is an unbound-variable error. local actual_length="$#" if [ "$expected" != "$actual_length" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "${label}" "$*" "to have length ${expected}" "but got ${actual_length}" return fi bashunit::state::add_assertions_passed } function assert_array_not_contains() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" shift local -a actual actual=("$@") case "${actual[*]:-}" in *"$expected"*) bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to not contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } # assert_dates.sh function bashunit::date::to_epoch() { local input="$1" # Already epoch seconds (all digits) case "$input" in *[!0-9]*) ;; # contains non-digits, continue to ISO parsing *) echo "$input" return 0 ;; esac # Handle Z (UTC) suffix explicitly: BusyBox needs TZ=UTC, BSD needs +0000 case "$input" in *Z) local utc_input="${input%Z}" local utc_norm="${utc_input/T/ }" local epoch # GNU/BusyBox: parse in explicit UTC epoch=$(TZ=UTC date -d "$utc_input" +%s 2>/dev/null) && { echo "$epoch"; return 0; } epoch=$(TZ=UTC date -d "$utc_norm" +%s 2>/dev/null) && { echo "$epoch"; return 0; } # BSD: use +0000 offset which %z understands epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "${utc_input}+0000" +%s 2>/dev/null) && { echo "$epoch"; return 0; } echo "$input" return 1 ;; esac # Normalize ISO 8601: replace T with space, strip tz offset local normalized="$input" normalized="${normalized/T/ }" # Strip timezone offset (+HHMM or -HHMM) at end for initial parsing case "$normalized" in *[+-][0-9][0-9][0-9][0-9]) normalized="${normalized%[+-][0-9][0-9][0-9][0-9]}" ;; esac # Format conversion (GNU vs BSD date) local epoch # Try GNU date first (-d flag) with original input epoch=$(date -d "$input" +%s 2>/dev/null) && { echo "$epoch" return 0 } # If input has timezone offset, parse in UTC and adjust manually (BusyBox) case "$input" in *[+-][0-9][0-9][0-9][0-9]) epoch=$(TZ=UTC date -d "$normalized" +%s 2>/dev/null) && { local ilen=${#input} local ostart=$((ilen - 5)) local osign="${input:$ostart:1}" local ohh="${input:$((ostart + 1)):2}" local omm="${input:$((ostart + 3)):2}" local osecs=$(( (10#$ohh * 3600) + (10#$omm * 60) )) if [ "$osign" = "+" ]; then osecs=$(( -osecs )) fi echo $(( epoch + osecs )) return 0 } ;; esac # Try GNU date with normalized (space-separated) input if [ "$normalized" != "$input" ]; then epoch=$(date -d "$normalized" +%s 2>/dev/null) && { echo "$epoch" return 0 } fi # Try BSD date (-j -f flag) with ISO 8601 datetime + timezone offset epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$input" +%s 2>/dev/null) && { echo "$epoch" return 0 } # Try BSD date with ISO 8601 datetime format epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$input" +%s 2>/dev/null) && { echo "$epoch" return 0 } # Try BSD date with space-separated datetime format epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$input" +%s 2>/dev/null) && { echo "$epoch" return 0 } # Try BSD date with date-only format (append midnight for deterministic results) epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$input 00:00:00" +%s 2>/dev/null) && { echo "$epoch" return 0 } # Unsupported format echo "$input" return 1 } function assert_date_equals() { bashunit::assert::should_skip && return 0 local expected expected="$(bashunit::date::to_epoch "$1")" local actual actual="$(bashunit::date::to_epoch "$2")" if [ "$actual" -ne "$expected" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to be equal to" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_date_before() { bashunit::assert::should_skip && return 0 local expected expected="$(bashunit::date::to_epoch "$1")" local actual actual="$(bashunit::date::to_epoch "$2")" if [ "$actual" -ge "$expected" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to be before" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_date_after() { bashunit::assert::should_skip && return 0 local expected expected="$(bashunit::date::to_epoch "$1")" local actual actual="$(bashunit::date::to_epoch "$2")" if [ "$actual" -le "$expected" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to be after" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_date_within_range() { bashunit::assert::should_skip && return 0 local from from="$(bashunit::date::to_epoch "$1")" local to to="$(bashunit::date::to_epoch "$2")" local actual actual="$(bashunit::date::to_epoch "$3")" if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to be between" "${from} and ${to}" return fi bashunit::state::add_assertions_passed } function assert_date_within_delta() { bashunit::assert::should_skip && return 0 local expected expected="$(bashunit::date::to_epoch "$1")" local actual actual="$(bashunit::date::to_epoch "$2")" local delta="$3" local diff=$((actual - expected)) if [ "$diff" -lt 0 ]; then diff=$((-diff)) fi if [ "$diff" -gt "$delta" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${actual}" "to be within" "${delta} seconds of ${expected}" return fi bashunit::state::add_assertions_passed } # assert_duration.sh function bashunit::duration::measure_ms() { local command="$1" local start_ns start_ns=$(bashunit::clock::now) eval "$command" >/dev/null 2>&1 local end_ns end_ns=$(bashunit::clock::now) local elapsed_ms elapsed_ms=$(bashunit::math::calculate "($end_ns - $start_ns) / 1000000" | awk '{printf "%.0f", $1}') echo "$elapsed_ms" } function assert_duration() { bashunit::assert::should_skip && return 0 local command="$1" local threshold_ms="$2" local elapsed_ms elapsed_ms=$(bashunit::duration::measure_ms "$command") if [ "$elapsed_ms" -gt "$threshold_ms" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${threshold_ms}" "to complete within (ms)" "${command}" return fi bashunit::state::add_assertions_passed } function assert_duration_less_than() { bashunit::assert::should_skip && return 0 local command="$1" local threshold_ms="$2" local elapsed_ms elapsed_ms=$(bashunit::duration::measure_ms "$command") if [ "$elapsed_ms" -ge "$threshold_ms" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${threshold_ms}" "to complete within (ms)" "${command}" return fi bashunit::state::add_assertions_passed } function assert_duration_greater_than() { bashunit::assert::should_skip && return 0 local command="$1" local threshold_ms="$2" local elapsed_ms elapsed_ms=$(bashunit::duration::measure_ms "$command") if [ "$elapsed_ms" -le "$threshold_ms" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${threshold_ms}" "to take at least (ms)" "${command}" return fi bashunit::state::add_assertions_passed } # assert_files.sh function assert_file_exists() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${3:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -f "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to exist but" "do not exist" return fi bashunit::state::add_assertions_passed } function assert_file_not_exists() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${3:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ -f "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to not exist but" "the file exists" return fi bashunit::state::add_assertions_passed } function assert_is_file() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${3:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -f "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to be a file" "but is not a file" return fi bashunit::state::add_assertions_passed } function assert_is_file_empty() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${3:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ -s "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to be empty" "but is not empty" return fi bashunit::state::add_assertions_passed } function assert_files_equals() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" if [ "$(diff -u "$expected" "$actual")" != '' ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ "Diff" "$(diff -u "$expected" "$actual" | sed '1,2d')" return fi bashunit::state::add_assertions_passed } function assert_files_not_equals() { bashunit::assert::should_skip && return 0 local expected="$1" local actual="$2" if [ "$(diff -u "$expected" "$actual")" = '' ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ "Diff" "Files are equals" return fi bashunit::state::add_assertions_passed } function assert_file_contains() { bashunit::assert::should_skip && return 0 local file="$1" local string="$2" if ! grep -F -q "$string" "$file"; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${file}" "to contain" "${string}" return fi bashunit::state::add_assertions_passed } function assert_file_not_contains() { bashunit::assert::should_skip && return 0 local file="$1" local string="$2" if grep -q "$string" "$file"; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${file}" "to not contain" "${string}" return fi bashunit::state::add_assertions_passed } ## # Normalizes an octal file mode to its decimal value, dropping leading zeros # (so "0755" and "755" compare equal). Echoes nothing on invalid octal input. # Arguments: $1 - octal mode string ## function bashunit::assert::_octal_to_decimal() { local mode="$1" case "$mode" in '' | *[!0-7]*) return 1 ;; esac printf '%d' "$((8#$mode))" } ## # Asserts a file has the expected octal permission mode (e.g. "644", "0755"). # Arguments: $1 - expected octal mode, $2 - file path ## function assert_file_permissions() { bashunit::assert::should_skip && return 0 local expected="$1" local file="$2" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" if [ ! -e "$file" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "${label}" "${file}" "to have permissions ${expected}" "but the file does not exist" return fi local actual actual="$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file" 2>/dev/null)" local expected_dec actual_dec expected_dec="$(bashunit::assert::_octal_to_decimal "$expected")" actual_dec="$(bashunit::assert::_octal_to_decimal "$actual")" if [ "$expected_dec" != "$actual_dec" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "${label}" "${file}" "to have permissions ${expected}" "but got ${actual}" return fi bashunit::state::add_assertions_passed } # assert_folders.sh function assert_directory_exists() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -d "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to exist but" "do not exist" return fi bashunit::state::add_assertions_passed } function assert_directory_not_exists() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ -d "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to not exist but" "the directory exists" return fi bashunit::state::add_assertions_passed } function assert_is_directory() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -d "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to be a directory" "but is not a directory" return fi bashunit::state::add_assertions_passed } function assert_is_directory_empty() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -d "$expected" ] || [ -n "$(ls -A "$expected")" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to be empty" "but is not empty" return fi bashunit::state::add_assertions_passed } function assert_is_directory_not_empty() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -d "$expected" ] || [ -z "$(ls -A "$expected")" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to not be empty" "but is empty" return fi bashunit::state::add_assertions_passed } function assert_is_directory_readable() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -d "$expected" ] || [ ! -r "$expected" ] || [ ! -x "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to be readable" "but is not readable" return fi bashunit::state::add_assertions_passed } function assert_is_directory_not_readable() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -d "$expected" ] || { [ -r "$expected" ] && [ -x "$expected" ]; }; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to be not readable" "but is readable" return fi bashunit::state::add_assertions_passed } function assert_is_directory_writable() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -d "$expected" ] || [ ! -w "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to be writable" "but is not writable" return fi bashunit::state::add_assertions_passed } function assert_is_directory_not_writable() { bashunit::assert::should_skip && return 0 local expected="$1" local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" if [ ! -d "$expected" ] || [ -w "$expected" ]; then bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "to be not writable" "but is writable" return fi bashunit::state::add_assertions_passed } # assert_json.sh function bashunit::assert_json::require_jq() { if ! command -v jq >/dev/null 2>&1; then bashunit::skip "jq is required for JSON assertions" return 1 fi return 0 } function assert_json_key_exists() { bashunit::assert::should_skip && return 0 bashunit::assert_json::require_jq || return 0 local key="$1" local json="$2" local result if ! result=$(printf '%s' "$json" | jq -e "$key" 2>/dev/null) || [ "$result" = "null" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${json}" "to have key" "${key}" return fi bashunit::state::add_assertions_passed } function assert_json_contains() { bashunit::assert::should_skip && return 0 bashunit::assert_json::require_jq || return 0 local key="$1" local expected="$2" local json="$3" local result if ! result=$(printf '%s' "$json" | jq -e -r "$key" 2>/dev/null) || [ "$result" = "null" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${json}" "to have key" "${key}" return fi if [ "$result" != "$expected" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${result}" return fi bashunit::state::add_assertions_passed } function assert_json_equals() { bashunit::assert::should_skip && return 0 bashunit::assert_json::require_jq || return 0 local expected="$1" local actual="$2" local expected_sorted expected_sorted=$(printf '%s' "$expected" | jq -S '.' 2>/dev/null) local actual_sorted actual_sorted=$(printf '%s' "$actual" | jq -S '.' 2>/dev/null) if [ "$expected_sorted" != "$actual_sorted" ]; then local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${actual}" return fi bashunit::state::add_assertions_passed } # assert_snapshot.sh # shellcheck disable=SC2155 # Strips all carriage returns, then any trailing newlines, entirely in bash. # Reproduces the previous `$(echo -n "$in" | tr -d '\r')` (command substitution # drops trailing newlines) without the two forks. Result in _snapshot_normalized. function bashunit::snapshot::normalize_actual() { local normalized="${1//$'\r'/}" while [ "${normalized%$'\n'}" != "$normalized" ]; do normalized="${normalized%$'\n'}" done _snapshot_normalized=$normalized } function assert_match_snapshot() { local _snapshot_normalized bashunit::snapshot::normalize_actual "$1" local actual=$_snapshot_normalized bashunit::helper::find_test_function_name_to_slot local test_fn=$_BASHUNIT_HELPER_TESTFN_OUT bashunit::snapshot::resolve_file "${2:-}" "$test_fn" local snapshot_file=$_BASHUNIT_SNAPSHOT_FILE_OUT if [ ! -f "$snapshot_file" ]; then bashunit::snapshot::initialize "$snapshot_file" "$actual" return fi bashunit::snapshot::compare "$actual" "$snapshot_file" "$test_fn" } function assert_match_snapshot_ignore_colors() { # Only fork sed when the input actually carries an escape sequence; plain, # colorless output takes a pure-bash fast path. The sed pattern is kept # identical to the historic one (strip `\x1B[...[mK]` only) so on-disk # snapshots stay byte-compatible. local stripped=$1 case "$stripped" in *$'\e'*) stripped=$(printf '%s' "$stripped" | sed 's/\x1B\[[0-9;]*[mK]//g') ;; esac local _snapshot_normalized bashunit::snapshot::normalize_actual "$stripped" local actual=$_snapshot_normalized bashunit::helper::find_test_function_name_to_slot local test_fn=$_BASHUNIT_HELPER_TESTFN_OUT bashunit::snapshot::resolve_file "${2:-}" "$test_fn" local snapshot_file=$_BASHUNIT_SNAPSHOT_FILE_OUT if [ ! -f "$snapshot_file" ]; then bashunit::snapshot::initialize "$snapshot_file" "$actual" return fi bashunit::snapshot::compare "$actual" "$snapshot_file" "$test_fn" } function bashunit::snapshot::match_with_placeholder() { local actual="$1" local snapshot="$2" local placeholder="${BASHUNIT_SNAPSHOT_PLACEHOLDER:-::ignore::}" local token="__BASHUNIT_IGNORE__" local sanitized="${snapshot//$placeholder/$token}" local escaped=$(printf '%s' "$sanitized" | sed -e 's/[.[\\^$*+?{}()|]/\\&/g') local regex="^${escaped//$token/(.|\\n)*}$" if command -v perl >/dev/null 2>&1; then echo "$actual" | REGEX="$regex" perl -0 -e ' my $r = $ENV{REGEX}; my $input = join("", ); exit($input =~ /$r/s ? 0 : 1); ' && return 0 || return 1 else # No perl: build the pattern exactly like the perl branch — swap the # placeholder for a token that survives escaping, escape the regex # metacharacters, then turn the token into `.*`. (The previous order, # escaping after inserting `.*`, escaped the `.*` itself and broke every # fallback match.) grep matches line-by-line, so unlike the perl branch a # placeholder cannot span multiple lines here. local fallback="${snapshot//$placeholder/$token}" fallback=$(printf '%s' "$fallback" | sed -e 's/[.[\\^$*+?{}()|]/\\&/g') fallback="^${fallback//$token/.*}$" echo "$actual" | grep -Eq "$fallback" && return 0 || return 1 fi } # Writes the resolved snapshot path into _BASHUNIT_SNAPSHOT_FILE_OUT (no fork). # Derives the path from BASH_SOURCE[2] using parameter expansion instead of # dirname/basename, keeping the exact string the previous version produced. _BASHUNIT_SNAPSHOT_FILE_OUT="" function bashunit::snapshot::resolve_file() { local file_hint="$1" local func_name="$2" if [ -n "$file_hint" ]; then _BASHUNIT_SNAPSHOT_FILE_OUT=$file_hint return fi # dirname via parameter expansion. `dirname "foo.sh"` (no slash) is ".", which # `${src%/*}` cannot yield, so special-case the slashless path. local src="${BASH_SOURCE[2]}" local dir_part case "$src" in */*) dir_part="${src%/*}" ;; *) dir_part="." ;; esac local base_part="${src##*/}" bashunit::helper::normalize_variable_name_to_slot "$base_part" local test_file=$_BASHUNIT_HELPER_VARNAME_OUT bashunit::helper::normalize_variable_name_to_slot "$func_name" local name="$_BASHUNIT_HELPER_VARNAME_OUT.snapshot" _BASHUNIT_SNAPSHOT_FILE_OUT="./${dir_part}/snapshots/${test_file}.${name}" } function bashunit::snapshot::initialize() { local path="$1" local content="$2" mkdir -p "$(dirname "$path")" echo "$content" >"$path" bashunit::state::add_assertions_snapshot } function bashunit::snapshot::compare() { local actual="$1" local snapshot_path="$2" local func_name="$3" # `$("$times_file" : >"$params_file" export "_BASHUNIT_SPY_${variable}_TIMES_FILE"="$times_file" export "_BASHUNIT_SPY_${variable}_PARAMS_FILE"="$params_file" local body_suffix="" if [[ "$exit_code_or_impl" =~ ^[0-9]+$ ]]; then body_suffix="return $exit_code_or_impl" elif [ -n "$exit_code_or_impl" ]; then body_suffix="$exit_code_or_impl \"\$@\"" fi eval "function $command() { local raw=\"\$*\" local serialized=\"\" local arg for arg in \"\$@\"; do serialized=\"\$serialized\$(builtin printf '%q' \"\$arg\")$'\\x1f'\" done serialized=\${serialized%$'\\x1f'} builtin printf '%s\x1e%s\\n' \"\$raw\" \"\$serialized\" >> '$params_file' local _c _c=\$(cat '$times_file' 2>/dev/null || builtin echo 0) _c=\$((_c+1)) builtin echo \"\$_c\" > '$times_file' $body_suffix }" export -f "${command?}" _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" } function assert_have_been_called() { local command=$1 local variable variable="$(bashunit::helper::normalize_variable_name "$command")" local file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" local times=0 if [ -f "${!file_var-}" ]; then times=$(cat "${!file_var}" 2>/dev/null || builtin echo 0) fi local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" if [ "$times" -eq 0 ]; then bashunit::state::add_assertions_failed bashunit::console_results::print_failed_test "${label}" "${command}" "to have been called" "once" return fi bashunit::state::add_assertions_passed } function assert_have_been_called_with() { local command=$1 shift local index="" # A trailing all-digits arg selects the nth recorded call. Pure-bash glob # avoids forking echo+grep on every assertion. case "${!#}" in '' | *[!0-9]*) ;; *) index=${!#} set -- "${@:1:$#-1}" ;; esac local expected="$*" local variable variable="$(bashunit::helper::normalize_variable_name "$command")" local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" local line="" if [ -f "${!file_var-}" ]; then if [ -n "$index" ]; then line=$(sed -n "${index}p" "${!file_var}" 2>/dev/null || true) else line=$(tail -n 1 "${!file_var}" 2>/dev/null || true) fi fi local raw IFS=$'\x1e' read -r raw _ <<<"$line" || true if [ "$expected" != "$raw" ]; then bashunit::state::add_assertions_failed bashunit::console_results::print_failed_test "$(bashunit::helper::normalize_test_function_name \ "${FUNCNAME[1]}")" "$expected" "but got " "$raw" return fi bashunit::state::add_assertions_passed } function assert_have_been_called_times() { local expected_count=$1 local command=$2 local variable variable="$(bashunit::helper::normalize_variable_name "$command")" local file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" local times=0 if [ -f "${!file_var-}" ]; then times=$(cat "${!file_var}" 2>/dev/null || builtin echo 0) fi local label="${3:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" if [ "$times" -ne "$expected_count" ]; then bashunit::state::add_assertions_failed bashunit::console_results::print_failed_test "${label}" "${command}" \ "to have been called" "${expected_count} times" \ "actual" "${times} times" return fi bashunit::state::add_assertions_passed } function assert_have_been_called_nth_with() { local nth=$1 local command=$2 shift 2 local expected="$*" local variable variable="$(bashunit::helper::normalize_variable_name "$command")" local times_file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" local label label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")" local times=0 if [ -f "${!times_file_var-}" ]; then times=$(cat "${!times_file_var}" 2>/dev/null || builtin echo 0) fi if [ "$nth" -gt "$times" ]; then bashunit::state::add_assertions_failed bashunit::console_results::print_failed_test "${label}" \ "expected call" "at index ${nth} but" "only called ${times} times" return fi local line="" if [ -f "${!file_var-}" ]; then line=$(sed -n "${nth}p" "${!file_var}" 2>/dev/null || true) fi local raw IFS=$'\x1e' read -r raw _ <<<"$line" || true if [ "$expected" != "$raw" ]; then bashunit::state::add_assertions_failed bashunit::console_results::print_failed_test "${label}" \ "$expected" "but got " "$raw" return fi bashunit::state::add_assertions_passed } function assert_not_called() { local command=$1 local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" assert_have_been_called_times 0 "$command" "$label" } # doc.sh # This function returns the embedded assertions.md content. # During development, it reads from the file. # During build, this function is replaced with actual content. function bashunit::doc::get_embedded_docs() { cat <<'__BASHUNIT_DOCS_EOF__' --- description: "Complete reference of bashunit assertions for testing bash scripts: assert equals, contains, matches, exit codes, files, arrays and more, with examples." --- # Assertions When creating tests, you'll need to verify your commands and functions. We provide assertions for these checks. Below is their documentation. ## assert_true > `assert_true bool|function|command` Reports an error if the argument result in a truthy value: `true` or `0`. - [assert_false](#assert-false) is similar but different. ::: code-group ```bash [Example] function test_success() { assert_true true assert_true 0 assert_true "eval return 0" assert_true mock_true } function test_failure() { assert_true false assert_true 1 assert_true "eval return 1" assert_true mock_false } ``` ```bash [globals.sh] function mock_true() { return 0 } function mock_false() { return 1 } ``` ::: ## assert_false > `assert_false bool|function|command` Reports an error if the argument result in a falsy value: `false` or `1`. - [assert_true](#assert-true) is similar but different. ::: code-group ```bash [Example] function test_success() { assert_false false assert_false 1 assert_false "eval return 1" assert_false mock_false } function test_failure() { assert_false true assert_false 0 assert_false "eval return 0" assert_false mock_true } ``` ```bash [globals.sh] function mock_true() { return 0 } function mock_false() { return 1 } ``` ::: ## assert_same > `assert_same "expected" "actual"` Reports an error if the `expected` and `actual` are not the same - including special chars. - [assert_not_same](#assert-not-same) is the inverse of this assertion and takes the same arguments. - [assert_equals](#assert-equals) is similar but ignoring the special chars. ::: code-group ```bash [Example] function test_success() { assert_same "foo" "foo" } function test_failure() { assert_same "foo" "bar" } ``` ::: ## assert_equals > `assert_equals "expected" "actual"` Reports an error if the two variables `expected` and `actual` are not equal ignoring the special chars like ANSI Escape Sequences (colors) and other special chars like tabs and new lines. - [assert_same](#assert-same) is similar but including special chars. ::: code-group ```bash [Example] function test_success() { assert_equals "foo" "\e[31mfoo" } function test_failure() { assert_equals "\e[31mfoo" "\e[31mfoo" } ``` ::: ## assert_contains > `assert_contains "needle" "haystack"` Reports an error if `needle` is not a substring of `haystack`. - [assert_not_contains](#assert-not-contains) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_contains "foo" "foobar" } function test_failure() { assert_contains "baz" "foobar" } ``` ::: ## assert_contains_ignore_case > `assert_contains_ignore_case "needle" "haystack"` Reports an error if `needle` is not a substring of `haystack`. Differences in casing are ignored when needle is searched for in haystack. ::: code-group ```bash [Example] function test_success() { assert_contains_ignore_case "foo" "FooBar" } function test_failure() { assert_contains_ignore_case "baz" "FooBar" } ``` ::: ## assert_empty > `assert_empty "actual"` Reports an error if `actual` is not empty. - [assert_not_empty](#assert-not-empty) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_empty "" } function test_failure() { assert_empty "foo" } ``` ::: ## assert_matches > `assert_matches "pattern" "value"` Reports an error if `value` does not match the regular expression `pattern`. - [assert_not_matches](#assert-not-matches) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_matches "^foo" "foobar" } function test_failure() { assert_matches "^bar" "foobar" } ``` ::: ## assert_string_starts_with > `assert_string_starts_with "needle" "haystack"` Reports an error if `haystack` does not starts with `needle`. - [assert_string_not_starts_with](#assert-string-not-starts-with) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_string_starts_with "foo" "foobar" } function test_failure() { assert_string_starts_with "baz" "foobar" } ``` ::: ## assert_string_ends_with > `assert_string_ends_with "needle" "haystack"` Reports an error if `haystack` does not ends with `needle`. - [assert_string_not_ends_with](#assert-string-not-ends-with) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_string_ends_with "bar" "foobar" } function test_failure() { assert_string_ends_with "foo" "foobar" } ``` ::: ## assert_string_matches_format > `assert_string_matches_format "format" "value"` Reports an error if `value` does not match the `format` string. The format string uses PHPUnit-style placeholders: | Placeholder | Matches | |-------------|---------| | `%d` | One or more digits | | `%i` | Signed integer (e.g. `+1`, `-42`) | | `%f` | Floating point number (e.g. `3.14`) | | `%s` | One or more non-whitespace characters | | `%x` | Hexadecimal (e.g. `ff00ab`) | | `%e` | Scientific notation (e.g. `1.5e10`) | | `%%` | Literal `%` character | - [assert_string_not_matches_format](#assert-string-not-matches-format) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_string_matches_format "%d items found" "42 items found" assert_string_matches_format "%s has %d items at %f each" "cart has 5 items at 9.99 each" } function test_failure() { assert_string_matches_format "%d items" "hello world" } ``` ::: ## assert_line_count > `assert_line_count "count" "haystack"` Reports an error if `haystack` does not contain `count` lines. ::: code-group ```bash [Example] function test_success() { local string="this is line one this is line two this is line three" assert_line_count 3 "$string" } function test_failure() { assert_line_count 2 "foobar" } ``` ::: ## assert_less_than > `assert_less_than "expected" "actual"` Reports an error if `actual` is not less than `expected`. - [assert_greater_than](#assert-greater-than) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_less_than "999" "1" } function test_failure() { assert_less_than "1" "999" } ``` ::: ## assert_less_or_equal_than > `assert_less_or_equal_than "expected" "actual"` Reports an error if `actual` is not less than or equal to `expected`. - [assert_greater_than](#assert-greater-or-equal-than) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_less_or_equal_than "999" "1" } function test_success_with_two_equal_numbers() { assert_less_or_equal_than "999" "999" } function test_failure() { assert_less_or_equal_than "1" "999" } ``` ::: ## assert_greater_than > `assert_greater_than "expected" "actual"` Reports an error if `actual` is not greater than `expected`. - [assert_less_than](#assert-less-than) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_greater_than "1" "999" } function test_failure() { assert_greater_than "999" "1" } ``` ::: ## assert_greater_or_equal_than > `assert_greater_or_equal_than "expected" "actual"` Reports an error if `actual` is not greater than or equal to `expected`. - [assert_less_or_equal_than](#assert-less-or-equal-than) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_greater_or_equal_than "1" "999" } function test_success_with_two_equal_numbers() { assert_greater_or_equal_than "999" "999" } function test_failure() { assert_greater_or_equal_than "999" "1" } ``` ::: ## assert_within_delta > `assert_within_delta "expected" "actual" "delta"` Reports an error if `actual` is not within `delta` of `expected` (i.e. `|actual - expected| > delta`). Supports floating-point values. Useful for timing or measured values where exact equality is too strict. ::: code-group ```bash [Example] function test_success() { assert_within_delta "3.14159" "3.14" "0.01" } function test_failure() { assert_within_delta "100" "105" "3" } ``` ::: ## assert_date_equals > `assert_date_equals "expected" "actual"` Reports an error if the two date values `expected` and `actual` are not equal. Inputs are automatically converted to epoch seconds. Supported formats: - Epoch seconds (integers): `1700000000` - ISO 8601 date: `2023-11-14` - ISO 8601 datetime: `2023-11-14T12:00:00` - ISO 8601 datetime with UTC Z: `2023-11-14T12:00:00Z` - ISO 8601 datetime with timezone offset: `2023-11-14T12:00:00+0100` - Space-separated datetime: `2023-11-14 12:00:00` You can mix formats in the same assertion (e.g., one epoch, one ISO). ::: code-group ```bash [Example] function test_success() { local now now="$(date +%s)" assert_date_equals "$now" "$now" } function test_failure() { assert_date_equals "1700000000" "1600000000" } ``` ::: ## assert_date_before > `assert_date_before "expected" "actual"` Reports an error if `actual` is not before `expected` (i.e. `actual` must be less than `expected`). Inputs are automatically converted to epoch seconds. See [assert_date_equals](#assert_date_equals) for supported formats. ::: code-group ```bash [Example] function test_success() { assert_date_before "1700000000" "1600000000" } function test_failure() { assert_date_before "1700000000" "1800000000" } ``` ::: ## assert_date_after > `assert_date_after "expected" "actual"` Reports an error if `actual` is not after `expected` (i.e. `actual` must be greater than `expected`). Inputs are automatically converted to epoch seconds. See [assert_date_equals](#assert_date_equals) for supported formats. ::: code-group ```bash [Example] function test_success() { assert_date_after "1600000000" "1700000000" } function test_failure() { assert_date_after "1600000000" "1500000000" } ``` ::: ## assert_date_within_range > `assert_date_within_range "from" "to" "actual"` Reports an error if `actual` does not fall between `from` and `to` (inclusive). Inputs are automatically converted to epoch seconds. See [assert_date_equals](#assert_date_equals) for supported formats. ::: code-group ```bash [Example] function test_success() { assert_date_within_range "1600000000" "1800000000" "1700000000" } function test_failure() { assert_date_within_range "1600000000" "1800000000" "1900000000" } ``` ::: ## assert_date_within_delta > `assert_date_within_delta "expected" "actual" "delta"` Reports an error if `actual` is not within `delta` seconds of `expected`. Inputs are automatically converted to epoch seconds. See [assert_date_equals](#assert_date_equals) for supported formats. ::: code-group ```bash [Example] function test_success() { local now now="$(date +%s)" local five_seconds_later=$(( now + 5 )) assert_date_within_delta "$now" "$five_seconds_later" "10" } function test_failure() { assert_date_within_delta "1700000000" "1700000020" "5" } ``` ::: ## assert_exit_code > `assert_exit_code "expected"` Reports an error if the exit code of the last executed command is not equal to `expected`. This assertion captures `$?` from the command executed **before** calling the assertion. It does **not** execute a string command passed as a second parameter. ::: tip Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code: `assert_exec "your_command" --exit 0` ::: - [assert_successful_code](#assert-successful-code), [assert_unsuccessful_code](#assert-unsuccessful-code), [assert_general_error](#assert-general-error) and [assert_command_not_found](#assert-command-not-found) are more semantic versions of this assertion, for which you don't need to specify an exit code. ::: code-group ```bash [Example] function test_success_checking_previous_command() { function foo() { return 1 } foo assert_exit_code "1" } function test_success_with_external_command() { touch /tmp/myfile assert_exit_code "0" } function test_failure() { function foo() { return 1 } foo assert_exit_code "0" } ``` ::: ## assert_exec > `assert_exec "command" [--exit ] [--stdout "text"] [--stderr "text"] [--stdout-contains "needle"] [--stdout-not-contains "needle"] [--stderr-contains "needle"] [--stderr-not-contains "needle"] [--stdin "input"]` Runs `command` capturing its exit status, standard output and standard error and checks all provided expectations. When `--exit` is omitted the expected exit status defaults to `0`. Use `--stdin` to feed input into interactive commands (e.g. commands using `read`). Multiple answers can be passed by separating them with newlines. Use `--stdout-contains` / `--stdout-not-contains` (and the `stderr-*` variants) for substring matching when you don't want to assert against the full output. ::: code-group ```bash [Example] function sample() { echo "out" echo "err" >&2 return 1 } function test_success() { assert_exec sample --exit 1 --stdout "out" --stderr "err" } function test_failure() { assert_exec sample --exit 0 --stdout "out" --stderr "err" } ``` ```bash [Interactive] function question() { local name lang read -r name read -r lang echo "Your name is $name and you prefer $lang." } function test_interactive_prompt() { assert_exec question \ --stdin "Chemaclass"$'\n'"Phel-Lang"$'\n' \ --stdout-contains "Your name is Chemaclass and you prefer Phel-Lang." \ --stdout-not-contains "Delphi" \ --exit 0 } ``` ::: ## assert_arrays_equal > `assert_arrays_equal "expected..." -- "actual..."` Reports an error if the arrays have different lengths or any element differs at the same index. Use `--` to separate the expected array from the actual array. ::: code-group ```bash [Example] function test_success() { local expected=(foo bar baz) local actual=(foo bar baz) assert_arrays_equal "${expected[@]}" -- "${actual[@]}" } function test_failure() { local expected=(foo bar baz) local actual=(foo baz bar) assert_arrays_equal "${expected[@]}" -- "${actual[@]}" } ``` ::: ## assert_array_contains > `assert_array_contains "needle" "haystack"` Reports an error if `needle` is not an element of `haystack`. - [assert_array_not_contains](#assert-array-not-contains) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local haystack=(foo bar baz) assert_array_contains "bar" "${haystack[@]}" } function test_failure() { local haystack=(foo bar baz) assert_array_contains "foobar" "${haystack[@]}" } ``` ::: ## assert_array_length > `assert_array_length "expected_length" "array"` Reports an error if `array` does not have exactly `expected_length` elements. ::: code-group ```bash [Example] function test_success() { local haystack=(foo bar baz) assert_array_length 3 "${haystack[@]}" } function test_failure() { local haystack=(foo bar baz) assert_array_length 2 "${haystack[@]}" } ``` ::: ## assert_successful_code > `assert_successful_code` Reports an error if the exit code of the last executed command is not successful (`0`). This assertion captures `$?` from the command executed **before** calling the assertion. It does **not** execute a string command passed as a parameter. ::: tip Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code: `assert_exec "your_command"` (defaults to expecting exit code 0) ::: - [assert_exit_code](#assert-exit-code) is the full version of this assertion where you can specify the expected exit code. ::: code-group ```bash [Example] function test_success_with_function() { function foo() { return 0 } foo assert_successful_code } function test_success_with_external_command() { touch /tmp/myfile assert_successful_code } function test_failure() { function foo() { return 1 } foo assert_successful_code } ``` ::: ## assert_unsuccessful_code > `assert_unsuccessful_code` Reports an error if the exit code of the last executed command is not unsuccessful (non-zero). This assertion captures `$?` from the command executed **before** calling the assertion. It does **not** execute a string command passed as a parameter. ::: tip Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code: `assert_exec "your_command" --exit 1` ::: - [assert_exit_code](#assert-exit-code) is the full version of this assertion where you can specify the expected exit code. ::: code-group ```bash [Example] function test_success_with_function() { function foo() { return 1 } foo assert_unsuccessful_code } function test_success_with_failing_command() { ls /nonexistent_path 2>/dev/null assert_unsuccessful_code } function test_failure() { function foo() { return 0 } foo assert_unsuccessful_code } ``` ::: ## assert_general_error > `assert_general_error` Reports an error if the exit code of the last executed command is not a general error (`1`). This assertion captures `$?` from the command executed **before** calling the assertion. It does **not** execute a string command passed as a parameter. ::: tip Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code: `assert_exec "your_command" --exit 1` ::: - [assert_exit_code](#assert-exit-code) is the full version of this assertion where you can specify the expected exit code. ::: code-group ```bash [Example] function test_success_with_function() { function foo() { return 1 } foo assert_general_error } function test_success_with_external_command() { grep "nonexistent" /dev/null assert_general_error } function test_failure() { function foo() { return 0 } foo assert_general_error } ``` ::: ## assert_command_not_found > `assert_command_not_found` Reports an error if the last executed command did not return a "command not found" exit code (`127`). This assertion captures `$?` from the command executed **before** calling the assertion. It does **not** execute a string command passed as a parameter. ::: tip Use [assert_exec](#assert-exec) if you want to pass a command as a string and check its exit code: `assert_exec "nonexistent_command" --exit 127` ::: - [assert_exit_code](#assert-exit-code) is the full version of this assertion where you can specify the expected exit code. ::: code-group ```bash [Example] function test_success_with_nonexistent_command() { nonexistent_command 2>/dev/null assert_command_not_found } function test_failure_with_existing_command() { ls > /dev/null 2>&1 assert_command_not_found } ``` ::: ## assert_file_exists > `assert_file_exists "file"` Reports an error if `file` does not exists, or it is a directory. - [assert_file_not_exists](#assert-file-not-exists) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local file_path="foo.txt" touch "$file_path" assert_file_exists "$file_path" rm "$file_path" } function test_failure() { local file_path="foo.txt" rm -f $file_path assert_file_exists "$file_path" } ``` ::: ## assert_file_contains > `assert_file_contains "file" "search"` Reports an error if `file` does not contains the search string. - [assert_file_not_contains](#assert-file-not-contains) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local file="/tmp/file-path.txt" echo -e "original content" > "$file" assert_file_contains "$file" "content" } function test_failure() { local file="/tmp/file-path.txt" echo -e "original content" > "$file" assert_file_contains "$file" "non existing" } ``` ::: ## assert_file_permissions > `assert_file_permissions "mode" "file"` Reports an error if `file` does not have the expected octal permission `mode` (e.g. `644`, `0755`). A leading zero is optional (`0755` and `755` are equal). Works on both Linux (GNU `stat`) and macOS (BSD `stat`). ::: code-group ```bash [Example] function test_success() { local file="/tmp/file-path.txt" touch "$file" chmod 600 "$file" assert_file_permissions "600" "$file" } function test_failure() { local file="/tmp/file-path.txt" touch "$file" chmod 644 "$file" assert_file_permissions "600" "$file" } ``` ::: ## assert_is_file > `assert_is_file "file"` Reports an error if `file` is not a file. ::: code-group ```bash [Example] function test_success() { local file_path="foo.txt" touch "$file_path" assert_is_file "$file_path" rm "$file_path" } function test_failure() { local dir_path="bar" mkdir "$dir_path" assert_is_file "$dir_path" rmdir "$dir_path" } ``` ::: ## assert_is_file_empty > `assert_is_file_empty "file"` Reports an error if `file` is not empty. ::: code-group ```bash [Example] function test_success() { local file_path="foo.txt" touch "$file_path" assert_is_file_empty "$file_path" rm "$file_path" } function test_failure() { local file_path="foo.txt" echo "bar" > "$file_path" assert_is_file_empty "$file_path" rm "$file_path" } ``` ::: ## assert_directory_exists > `assert_directory_exists "directory"` Reports an error if `directory` does not exist. - [assert_directory_not_exists](#assert-directory-not-exists) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local directory="/var" assert_directory_exists "$directory" } function test_failure() { local directory="/nonexistent_directory" assert_directory_exists "$directory" } ``` ::: ## assert_is_directory > `assert_is_directory "directory"` Reports an error if `directory` is not a directory. ::: code-group ```bash [Example] function test_success() { local directory="/var" assert_is_directory "$directory" } function test_failure() { local file="/etc/hosts" assert_is_directory "$file" } ``` ::: ## assert_is_directory_empty > `assert_is_directory_empty "directory"` Reports an error if `directory` is not an empty directory. - [assert_is_directory_not_empty](#assert-is-directory-not-empty) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local directory="/home/user/empty_directory" mkdir "$directory" assert_is_directory_empty "$directory" } function test_failure() { local directory="/etc" assert_is_directory_empty "$directory" } ``` ::: ## assert_is_directory_readable > `assert_is_directory_readable "directory"` Reports an error if `directory` is not a readable directory. - [assert_is_directory_not_readable](#assert-is-directory-not-readable) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local directory="/var" assert_is_directory_readable "$directory" } function test_failure() { local directory="/home/user/test" chmod -r "$directory" assert_is_directory_readable "$directory" } ``` ::: ## assert_is_directory_writable > `assert_is_directory_writable "directory"` Reports an error if `directory` is not a writable directory. - [assert_is_directory_not_writable](#assert-is-directory-not-writable) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local directory="/tmp" assert_is_directory_writable "$directory" } function test_failure() { local directory="/home/user/test" chmod -w "$directory" assert_is_directory_writable "$directory" } ``` ::: ## assert_files_equals > `assert_files_equals "expected" "actual"` Reports an error if `expected` and `actual` are not equals. - [assert_files_not_equals](#assert-files-not-equals) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local expected="/tmp/file1.txt" local actual="/tmp/file2.txt" echo "file content" > "$expected" echo "file content" > "$actual" assert_files_equals "$expected" "$actual" } function test_failure() { local expected="/tmp/file1.txt" local actual="/tmp/file2.txt" echo "file content" > "$expected" echo "different content" > "$actual" assert_files_equals "$expected" "$actual" } ``` ```[Output] ✓ Passed: Success ✗ Failed: Failure Expected '/tmp/file1.txt' Compared '/tmp/file2.txt' Diff '@@ -1 +1 @@ -file content +different content' ``` ::: ## assert_not_same > `assert_not_same "expected" "actual"` Reports an error if the two variables `expected` and `actual` are the same value. - [assert_same](#assert-same) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_not_same "foo" "bar" } function test_failure() { assert_not_same "foo" "foo" } ``` ::: ## assert_not_contains > `assert_not_contains "needle" "haystack"` Reports an error if `needle` is a substring of `haystack`. - [assert_contains](#assert-contains) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_not_contains "baz" "foobar" } function test_failure() { assert_not_contains "foo" "foobar" } ``` ::: ## assert_string_not_starts_with > `assert_string_not_starts_with "needle" "haystack"` Reports an error if `haystack` does starts with `needle`. - [assert_string_starts_with](#assert-string-starts-with) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_string_not_starts_with "bar" "foobar" } function test_failure() { assert_string_not_starts_with "foo" "foobar" } ``` ::: ## assert_string_not_ends_with > `assert_string_not_ends_with "needle" "haystack"` Reports an error if `haystack` does ends with `needle`. - [assert_string_ends_with](#assert-string-ends-with) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_string_not_ends_with "foo" "foobar" } function test_failure() { assert_string_not_ends_with "bar" "foobar" } ``` ::: ## assert_not_empty > `assert_not_empty "actual"` Reports an error if `actual` is empty. - [assert_empty](#assert-empty) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_not_empty "foo" } function test_failure() { assert_not_empty "" } ``` ::: ## assert_not_matches > `assert_not_matches "pattern" "value"` Reports an error if `value` matches the regular expression `pattern`. - [assert_matches](#assert-matches) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_not_matches "foo$" "foobar" } function test_failure() { assert_not_matches "bar$" "foobar" } ``` ::: ## assert_string_not_matches_format > `assert_string_not_matches_format "format" "value"` Reports an error if `value` matches the `format` string. See [assert_string_matches_format](#assert-string-matches-format) for supported placeholders. - [assert_string_matches_format](#assert-string-matches-format) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_string_not_matches_format "%d items" "hello world" } function test_failure() { assert_string_not_matches_format "%d items" "42 items" } ``` ::: ## assert_array_not_contains > `assert_array_not_contains "needle" "haystack"` Reports an error if `needle` is an element of `haystack`. - [assert_array_contains](#assert-array-contains) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local haystack=(foo bar baz) assert_array_not_contains "foobar" "${haystack[@]}" } function test_failure() { local haystack=(foo bar baz) assert_array_not_contains "baz" "${haystack[@]}" } ``` ::: ## assert_file_not_exists > `assert_file_not_exists "file"` Reports an error if `file` does exists. - [assert_file_exists](#assert-file-exists) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local file_path="foo.txt" touch "$file_path" rm "$file_path" assert_file_not_exists "$file_path" } function test_failed() { local file_path="foo.txt" touch "$file_path" assert_file_not_exists "$file_path" rm "$file_path" } ``` ::: ## assert_file_not_contains > `assert_file_not_contains "file" "search"` Reports an error if `file` contains the search string. - [assert_file_contains](#assert-file-contains) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local file="/tmp/file-path.txt" echo -e "original content" > "$file" assert_file_not_contains "$file" "non existing" } function test_failure() { local file="/tmp/file-path.txt" echo -e "original content" > "$file" assert_file_not_contains "$file" "content" } ``` ::: ## assert_directory_not_exists > `assert_directory_not_exists "directory"` Reports an error if `directory` exists. - [assert_directory_exists](#assert-directory-exists) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local directory="/nonexistent_directory" assert_directory_not_exists "$directory" } function test_failure() { local directory="/var" assert_directory_not_exists "$directory" } ``` ::: ## assert_is_directory_not_empty > `assert_is_directory_not_empty "directory"` Reports an error if `directory` is empty. - [assert_is_directory_empty](#assert-is-directory-empty) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local directory="/etc" assert_is_directory_not_empty "$directory" } function test_failure() { local directory="/home/user/empty_directory" mkdir "$directory" assert_is_directory_not_empty "$directory" } ``` ::: ## assert_is_directory_not_readable > `assert_is_directory_not_readable "directory"` Reports an error if `directory` is readable. - [assert_is_directory_readable](#assert-is-directory-readable) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local directory="/home/user/test" chmod -r "$directory" assert_is_directory_not_readable "$directory" } function test_failure() { local directory="/var" assert_is_directory_not_readable "$directory" } ``` ::: ## assert_is_directory_not_writable > `assert_is_directory_not_writable "directory"` Reports an error if `directory` is writable. - [assert_is_directory_writable](#assert-is-directory-writable) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local directory="/home/user/test" chmod -w "$directory" assert_is_directory_not_writable "$directory" } function test_failure() { local directory="/tmp" assert_is_directory_not_writable "$directory" } ``` ::: ## assert_files_not_equals > `assert_files_not_equals "expected" "actual"` Reports an error if `expected` and `actual` are not equals. - [assert_files_equals](#assert-files-equals) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { local expected="/tmp/file1.txt" local actual="/tmp/file2.txt" echo "file content" > "$expected" echo "different content" > "$actual" assert_files_not_equals "$expected" "$actual" } function test_failure() { local expected="/tmp/file1.txt" local actual="/tmp/file2.txt" echo "file content" > "$expected" echo "file content" > "$actual" assert_files_not_equals "$expected" "$actual" } ``` ```[Output] ✓ Passed: Success ✗ Failed: Failure Expected '/tmp/file1.txt' Compared '/tmp/file2.txt' Diff 'Files are equals' ``` ::: ## assert_json_key_exists > `assert_json_key_exists "key" "json"` Reports an error if `key` does not exist in the JSON string. Uses [jq](https://jqlang.github.io/jq/) syntax for key paths. Requires `jq` to be installed; if missing the test is skipped. ::: code-group ```bash [Example] function test_success() { assert_json_key_exists ".name" '{"name":"bashunit","version":"1.0"}' assert_json_key_exists ".data.id" '{"data":{"id":42}}' } function test_failure() { assert_json_key_exists ".missing" '{"name":"bashunit"}' } ``` ::: ## assert_json_contains > `assert_json_contains "key" "expected" "json"` Reports an error if `key` does not exist in the JSON string or its value does not equal `expected`. Uses [jq](https://jqlang.github.io/jq/) syntax for key paths. Requires `jq` to be installed; if missing the test is skipped. ::: code-group ```bash [Example] function test_success() { assert_json_contains ".name" "bashunit" '{"name":"bashunit","version":"1.0"}' assert_json_contains ".count" "42" '{"count":42}' } function test_failure() { assert_json_contains ".name" "other" '{"name":"bashunit"}' assert_json_contains ".missing" "value" '{"name":"bashunit"}' } ``` ::: ## assert_json_equals > `assert_json_equals "expected" "actual"` Reports an error if the two JSON strings are not structurally equal. Key order is ignored. Requires `jq` to be installed; if missing the test is skipped. ::: code-group ```bash [Example] function test_success() { assert_json_equals '{"b":2,"a":1}' '{"a":1,"b":2}' } function test_failure() { assert_json_equals '{"a":1}' '{"a":2}' } ``` ::: ## assert_duration > `assert_duration "command" threshold_ms` Reports an error if `command` takes longer than `threshold_ms` milliseconds to execute. Uses the framework's portable clock internally. ::: code-group ```bash [Example] function test_success() { assert_duration "echo hello" 500 } function test_failure() { assert_duration "sleep 2" 1000 } ``` ::: ## assert_duration_less_than > `assert_duration_less_than "command" threshold_ms` Reports an error if `command` takes `threshold_ms` milliseconds or more to execute. Stricter than [assert_duration](#assert-duration) which allows equal values. ::: code-group ```bash [Example] function test_success() { assert_duration_less_than "echo hello" 500 } function test_failure() { assert_duration_less_than "sleep 2" 1000 } ``` ::: ## assert_duration_greater_than > `assert_duration_greater_than "command" threshold_ms` Reports an error if `command` completes in `threshold_ms` milliseconds or less. Useful for verifying that a command takes at least a minimum amount of time. ::: code-group ```bash [Example] function test_success() { assert_duration_greater_than "sleep 1" 500 } function test_failure() { assert_duration_greater_than "echo hello" 5000 } ``` ::: ## bashunit::fail > `bashunit::fail "failure message"` Unambiguously reports an error message. Useful for reporting specific message when testing situations not covered by any `assert_*` functions. ::: code-group ```bash [Example] function test_success() { if [ "$(date +%-H)" -gt 25 ]; then bashunit::fail "Something is very wrong with your clock" fi } function test_failure() { if [ "$(date +%-H)" -lt 25 ]; then bashunit::fail "This test will always fail" fi } ``` ::: ## Related - [Custom asserts](/custom-asserts) — build your own domain-specific assertions - [Test doubles](/test-doubles) — mocks and spies for isolated tests - [Data providers](/data-providers) — run the same assertions over many inputs - [Globals](/globals) — `bashunit::` helper functions __BASHUNIT_DOCS_EOF__ } # Single awk pass over the embedded docs: the previous line-by-line shell loop # forked an `echo | sed` pipe per line (~3.2k forks, ~5s for the ~1.6k-line # docs page); one awk fork does the same work in milliseconds (#832). function bashunit::doc::print_asserts() { local filter="${1:-}" bashunit::doc::get_embedded_docs | awk -v filter="$filter" ' { if ($0 ~ /^## /) { # Heading word: the leading [A-Za-z0-9_]* run after "## ". Only # assert*/bashunit* headings are doc entries; prose headings like # "## Related" fall through and are treated as regular content. fn = substr($0, 4) sub(/[^A-Za-z0-9_].*$/, "", fn) if (fn ~ /^(assert|bashunit)/) { if (filter == "" || index(fn, filter) > 0) { should_print = 1 print $0 doc = "" } else { should_print = 0 } next } } if (should_print) { if ($0 ~ /^```/) { print "--------------" print doc should_print = 0 next } if ($0 ~ /^::: code-group/) next # Remove markdown link brackets and anchor tags. The bracket class # uses the POSIX []][ idiom: busybox awk (Alpine) rejects # backslash-escaped brackets inside a bracket expression. line = $0 gsub(/[][]/, "", line) gsub(/ *\(#[-a-z0-9]+\)/, "", line) doc = doc line "\n" } } ' } # reports.sh # shellcheck disable=SC2155 _BASHUNIT_REPORTS_TEST_FILES=() _BASHUNIT_REPORTS_TEST_NAMES=() _BASHUNIT_REPORTS_TEST_STATUSES=() _BASHUNIT_REPORTS_TEST_DURATIONS=() _BASHUNIT_REPORTS_TEST_ASSERTIONS=() _BASHUNIT_REPORTS_TEST_FAILURES=() _BASHUNIT_REPORTS_TEST_LINES=() function bashunit::reports::add_test_snapshot() { bashunit::reports::add_test "$1" "$2" "$3" "$4" "snapshot" } function bashunit::reports::add_test_incomplete() { bashunit::reports::add_test "$1" "$2" "$3" "$4" "incomplete" } function bashunit::reports::add_test_skipped() { bashunit::reports::add_test "$1" "$2" "$3" "$4" "skipped" } function bashunit::reports::add_test_passed() { bashunit::reports::add_test "$1" "$2" "$3" "$4" "passed" } function bashunit::reports::add_test_risky() { bashunit::reports::add_test "$1" "$2" "$3" "$4" "risky" } function bashunit::reports::add_test_failed() { bashunit::reports::add_test "$1" "$2" "$3" "$4" "failed" "$5" } # Returns 0 when any report output is requested. function bashunit::reports::is_enabled() { [ -n "${BASHUNIT_LOG_JUNIT:-}" ] || [ -n "${BASHUNIT_REPORT_HTML:-}" ] || [ -n "${BASHUNIT_LOG_GHA:-}" ] || [ -n "${BASHUNIT_REPORT_TAP:-}" ] || [ -n "${BASHUNIT_REPORT_JSON:-}" ] } function bashunit::reports::add_test() { # Skip tracking when no report output is requested bashunit::reports::is_enabled || return 0 local file="$1" local test_name="$2" local duration="$3" local assertions="$4" local status="$5" local failure_message="${6:-}" # Capture the line number from the current test location ("file:line"), # but only when it belongs to this test's file, so a stale location from a # prior test never mislabels this entry. local line="" case "${_BASHUNIT_TEST_LOCATION:-}" in "$file":*) line="${_BASHUNIT_TEST_LOCATION##*:}" ;; esac _BASHUNIT_REPORTS_TEST_FILES[${#_BASHUNIT_REPORTS_TEST_FILES[@]}]="$file" _BASHUNIT_REPORTS_TEST_NAMES[${#_BASHUNIT_REPORTS_TEST_NAMES[@]}]="$test_name" _BASHUNIT_REPORTS_TEST_STATUSES[${#_BASHUNIT_REPORTS_TEST_STATUSES[@]}]="$status" _BASHUNIT_REPORTS_TEST_ASSERTIONS[${#_BASHUNIT_REPORTS_TEST_ASSERTIONS[@]}]="$assertions" _BASHUNIT_REPORTS_TEST_DURATIONS[${#_BASHUNIT_REPORTS_TEST_DURATIONS[@]}]="$duration" _BASHUNIT_REPORTS_TEST_FAILURES[${#_BASHUNIT_REPORTS_TEST_FAILURES[@]}]="$failure_message" _BASHUNIT_REPORTS_TEST_LINES[${#_BASHUNIT_REPORTS_TEST_LINES[@]}]="$line" } function bashunit::reports::__xml_escape() { local text="$1" # Strip ANSI escape sequences and control characters invalid in XML 1.0, # then escape XML special characters (& first to avoid double-escaping) echo "$text" \ | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' \ | tr -d '\000-\010\013\014\016-\037' \ | sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' -e "s/'/\'/g" } # Escapes a string for embedding in a JSON string literal (pure Bash, no jq). # Strips ANSI/control chars that cannot appear inline, keeps \t\r\n as escapes. function bashunit::reports::__json_escape() { local text="$1" text=$(printf '%s' "$text" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' | tr -d '\000-\010\013\014\016-\037') # Backslash first so escapes added below are not doubled. text="${text//\\/\\\\}" text="${text//\"/\\\"}" text="${text//$'\t'/\\t}" text="${text//$'\r'/\\r}" text="${text//$'\n'/\\n}" printf '%s' "$text" } function bashunit::reports::generate_junit_xml() { local output_file="$1" local tests_skipped=$(bashunit::state::get_tests_skipped) local tests_incomplete=$(bashunit::state::get_tests_incomplete) local tests_failed=$(bashunit::state::get_tests_failed) local time_ms=$(bashunit::clock::total_runtime_in_milliseconds) local time time=$(LC_ALL=C awk -v ms="$time_ms" 'BEGIN {printf "%.3f", ms/1000}') { echo "" echo "" echo " " local i for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}" local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}" local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" local test_time_ms="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}" local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}" local test_time test_time=$(LC_ALL=C awk -v ms="$test_time_ms" 'BEGIN {printf "%.3f", ms/1000}') echo " " # Add failure element for failed tests with actual failure message if [ "$status" = "failed" ]; then local escaped_message escaped_message=$(bashunit::reports::__xml_escape "$failure_message") echo " $escaped_message" elif [ "$status" = "risky" ]; then echo " " elif [ "$status" = "skipped" ]; then echo " " elif [ "$status" = "incomplete" ]; then echo " " fi echo " " done echo " " echo "" } >"$output_file" } ## # Prepares a failure message for a TAP YAML diagnostic block: strips ANSI escape # sequences, collapses newlines to spaces and doubles single quotes so the value # is safe inside a YAML single-quoted scalar. Bash 3.0+ compatible. ## function bashunit::reports::__tap_message() { echo "$1" \ | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' \ | tr '\n' ' ' \ | sed -e "s/'/''/g" } ## # Writes results in TAP version 13 format (https://testanything.org). # Passing/snapshot -> "ok", failed -> "not ok" with a YAML diagnostic, # skipped/risky -> "# SKIP", incomplete -> "# TODO". # Arguments: $1 - output file ## function bashunit::reports::generate_report_tap() { local output_file="$1" local total="${#_BASHUNIT_REPORTS_TEST_NAMES[@]}" { echo "TAP version 13" echo "1..$total" local i seq=0 for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do seq=$((seq + 1)) local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}" local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}" case "$status" in failed) echo "not ok $seq - $name" echo " ---" echo " message: '$(bashunit::reports::__tap_message "$failure_message")'" echo " ..." ;; skipped) echo "ok $seq - $name # SKIP" ;; risky) echo "ok $seq - $name # SKIP risky (no assertions)" ;; incomplete) echo "ok $seq - $name # TODO" ;; *) echo "ok $seq - $name" ;; esac done } >"$output_file" } function bashunit::reports::generate_report_json() { local output_file="$1" local total="${#_BASHUNIT_REPORTS_TEST_NAMES[@]}" local passed=0 failed=0 skipped=0 incomplete=0 duration_total=0 local i for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do duration_total=$((duration_total + ${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-0})) case "${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" in failed) failed=$((failed + 1)) ;; skipped) skipped=$((skipped + 1)) ;; incomplete) incomplete=$((incomplete + 1)) ;; # snapshot and risky ran without failing, so they count as passed here; the # per-test "status" field below preserves the exact category. *) passed=$((passed + 1)) ;; esac done { printf '{\n' printf ' "summary": { "total": %d, "passed": %d, "failed": %d,' \ "$total" "$passed" "$failed" printf ' "skipped": %d, "incomplete": %d, "duration_ms": %d },\n' \ "$skipped" "$incomplete" "$duration_total" printf ' "tests": [\n' local seq=0 for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do local file name status duration message sep file=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}") name=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}") status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" duration="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-0}" message=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}") sep="," [ "$seq" -eq "$((total - 1))" ] && sep="" printf ' { "file": "%s", "name": "%s", "status": "%s", "duration_ms": %d, "message": "%s" }%s\n' \ "$file" "$name" "$status" "$duration" "$message" "$sep" seq=$((seq + 1)) done printf ' ]\n' printf '}\n' } >"$output_file" } function bashunit::reports::__gha_encode() { local text="$1" # Strip ANSI escape sequences first (one sed call) text=$(printf '%s' "$text" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g') # Percent-encode reserved chars per GHA workflow-commands spec. # Bash 3.0+ parameter expansion avoids extra awk/sed calls. # Order matters: encode '%' first so the sequences we inject stay literal. text="${text//%/%25}" text="${text//$'\r'/%0D}" text="${text//$'\n'/%0A}" printf '%s' "$text" } # Echoes GitHub Actions workflow-command annotations to stdout. # Arguments: $1 - "failed-only" to emit just errors (default: all reportable). function bashunit::reports::print_gha_annotations() { local only="${1:-all}" local i for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}" local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}" local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}" local line="${_BASHUNIT_REPORTS_TEST_LINES[$i]:-}" local level="" message="" case "$status" in failed) level="error" message="$failure_message" ;; risky) level="warning" message="Test has no assertions (risky)" ;; incomplete) level="notice" message="Test incomplete" ;; *) continue ;; esac if [ "$only" = "failed-only" ] && [ "$status" != "failed" ]; then continue fi local location="file=${file}" if [ -n "$line" ]; then location="${location},line=${line}" fi local encoded_message encoded_message=$(bashunit::reports::__gha_encode "$message") echo "::${level} ${location},title=${name}::${encoded_message}" done } function bashunit::reports::generate_gha_log() { local output_file="$1" bashunit::reports::print_gha_annotations all >"$output_file" } function bashunit::reports::generate_report_html() { local output_file="$1" local test_passed=$(bashunit::state::get_tests_passed) local tests_skipped=$(bashunit::state::get_tests_skipped) local tests_incomplete=$(bashunit::state::get_tests_incomplete) local tests_snapshot=$(bashunit::state::get_tests_snapshot) local tests_failed=$(bashunit::state::get_tests_failed) local time=$(bashunit::clock::total_runtime_in_milliseconds) # Temporary file to store test cases by file (use mktemp for parallel safety) local temp_file temp_file=$(mktemp "${TMPDIR:-/tmp}/bashunit-report.XXXXXX") # Collect test cases by file : >"$temp_file" # Clear temp file if it exists local i for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}" local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}" local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}" local test_case="$file|$name|$status|$test_time" echo "$test_case" >>"$temp_file" done { echo "" echo "" echo "" echo " " echo " " echo " Test Report" echo " " echo "" echo "" echo "

Test Report

" echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo "
Total TestsPassedFailedIncompleteSkippedSnapshotTime (ms)
${#_BASHUNIT_REPORTS_TEST_NAMES[@]}$test_passed$tests_failed$tests_incomplete$tests_skipped$tests_snapshot$time
" echo "

Time: $time ms

" # Read the temporary file and group by file local current_file="" local file name status test_time while IFS='|' read -r file name status test_time; do if [ "$file" != "$current_file" ]; then if [ -n "$current_file" ]; then echo " " echo " " fi echo "

File: $file

" echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " echo " " current_file="$file" fi echo " " echo " " echo " " echo " " echo " " done <"$temp_file" # Close the last table if [ -n "$current_file" ]; then echo " " echo "
Test NameStatusTime (ms)
$name$status$test_time
" fi echo "" echo "" } >"$output_file" # Clean up temporary file rm -f "$temp_file" } # rerun.sh ## # --rerun-failed support. # # Recording (every run, regardless of the flag): each failing test appends its # raw ":" identity to the shared collection temp file # RERUN_FAILED_OUTPUT_PATH (created in env.sh). File appends work across the # parallel test subshells, so both modes share one collector. At the end of a # run the collector is persisted to the cache file (deduped); a fully green run # truncates it. # # Replay (only with --rerun-failed): the cache is loaded, discovery is # restricted to the recorded files, and each file's functions are filtered to # the recorded names. --filter/--tag still apply on top (intersection). ## # Entries loaded for replay: newline-delimited ":". Empty when none. _BASHUNIT_RERUN_ENTRIES="" ## # Path to the persisted cache file. Defaults to ".bashunit/last-failed" under # the working directory; BASHUNIT_RERUN_CACHE_DIR overrides the directory. ## function bashunit::rerun::cache_file() { echo "${BASHUNIT_RERUN_CACHE_DIR:-.bashunit}/last-failed" } function bashunit::rerun::is_enabled() { [ "${BASHUNIT_RERUN_FAILED:-false}" = true ] } ## # Appends a failed test's raw identity to the collection temp file. # Arguments: $1 test file path, $2 raw function name. ## function bashunit::rerun::record() { local test_file=$1 local fn_name=$2 [ -n "${RERUN_FAILED_OUTPUT_PATH:-}" ] || return 0 printf '%s:%s\n' "$test_file" "$fn_name" >>"$RERUN_FAILED_OUTPUT_PATH" 2>/dev/null || true } ## # Persists the collected failures to the cache (deduped, first-seen order). # A run with no collected failures truncates an existing cache. Write errors # (e.g. a read-only working directory) are ignored silently. ## function bashunit::rerun::persist() { local cache cache="$(bashunit::rerun::cache_file)" local collected="${RERUN_FAILED_OUTPUT_PATH:-}" if [ -n "$collected" ] && [ -s "$collected" ]; then local dir="${cache%/*}" if [ "$dir" != "$cache" ]; then mkdir -p "$dir" 2>/dev/null || return 0 fi awk '!seen[$0]++' "$collected" >"$cache" 2>/dev/null || true elif [ -f "$cache" ]; then : >"$cache" 2>/dev/null || true fi } ## # Loads the cache into _BASHUNIT_RERUN_ENTRIES (empty when the cache is absent). ## function bashunit::rerun::load() { local cache cache="$(bashunit::rerun::cache_file)" _BASHUNIT_RERUN_ENTRIES="" [ -f "$cache" ] || return 0 _BASHUNIT_RERUN_ENTRIES="$(cat "$cache" 2>/dev/null)" } function bashunit::rerun::has_entries() { [ -n "$_BASHUNIT_RERUN_ENTRIES" ] } ## # Echoes the distinct test files from the loaded entries (first-seen order). ## function bashunit::rerun::files() { [ -n "$_BASHUNIT_RERUN_ENTRIES" ] || return 0 printf '%s\n' "$_BASHUNIT_RERUN_ENTRIES" | awk ' NF { file = $0 sub(/:[^:]*$/, "", file) if (!seen[file]++) print file }' } ## # Returns 0 when ":" is among the loaded entries. ## function bashunit::rerun::allows() { local file=$1 local fn=$2 case " $_BASHUNIT_RERUN_ENTRIES " in *" $file:$fn "*) return 0 ;; esac return 1 } ## # Filters a space-separated function list down to the ones recorded for a file. # Arguments: $1 test file path, $2 space-separated function names. ## function bashunit::rerun::filter_functions() { local file=$1 local functions=$2 local kept="" local fn for fn in $functions; do if bashunit::rerun::allows "$file" "$fn"; then kept="$kept $fn" fi done echo "${kept# }" } # runner.sh # shellcheck disable=SC2155 # Pre-compiled regex pattern for parsing test result assertions if [ -z "${_BASHUNIT_RUNNER_PARSE_RESULT_REGEX+x}" ]; then declare -r _BASHUNIT_RUNNER_PARSE_RESULT_REGEX='ASSERTIONS_FAILED=([0-9]*)##'\ 'ASSERTIONS_PASSED=([0-9]*)##ASSERTIONS_SKIPPED=([0-9]*)##'\ 'ASSERTIONS_INCOMPLETE=([0-9]*)##ASSERTIONS_SNAPSHOT=([0-9]*)##TEST_EXIT_CODE=([0-9]*)' fi function bashunit::runner::restore_workdir() { cd "$BASHUNIT_WORKING_DIR" 2>/dev/null || true } ## # Whether the running Bash has a reliable `set -o pipefail`. Bash 3.0 shipped a # broken pipefail (a failing pipeline can wrongly report success), which makes # `--strict` unsound; on 3.0 we fall back to `set -eu` without pipefail. # Returns: 0 when pipefail is reliable (Bash >= 3.1), 1 otherwise. ## function bashunit::runner::_supports_reliable_pipefail() { if [ "${BASH_VERSINFO[0]:-0}" -gt 3 ]; then return 0 fi [ "${BASH_VERSINFO[0]:-0}" -eq 3 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 1 ] } # Caches BASHUNIT_COVERAGE into _BASHUNIT_COVERAGE_ON ("1"|"0") so hot-path checks # avoid a function dispatch per call. Call once after arg parsing; tests that # toggle BASHUNIT_COVERAGE mid-run must call this again to refresh. function bashunit::runner::sync_coverage_flag() { if [ "${BASHUNIT_COVERAGE-}" = "true" ]; then _BASHUNIT_COVERAGE_ON=1 else _BASHUNIT_COVERAGE_ON=0 fi } function bashunit::runner::source_login_shell_profiles() { # shellcheck disable=SC1091 [ -f /etc/profile ] && source /etc/profile 2>/dev/null || true # shellcheck disable=SC1090 [ -f ~/.bash_profile ] && source ~/.bash_profile 2>/dev/null || true # shellcheck disable=SC1090 [ -f ~/.bash_login ] && source ~/.bash_login 2>/dev/null || true # shellcheck disable=SC1090 [ -f ~/.profile ] && source ~/.profile 2>/dev/null || true } function bashunit::runner::export_test_identity() { local test_file=$1 local fn_name=$2 bashunit::helper::generate_id "$fn_name" export BASHUNIT_CURRENT_TEST_ID="$_BASHUNIT_HELPER_ID_OUT" bashunit::runner::resolve_test_location "$test_file" "$fn_name" export _BASHUNIT_TEST_LOCATION if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then export _BASHUNIT_COVERAGE_CURRENT_TEST_FILE="$test_file" export _BASHUNIT_COVERAGE_CURRENT_TEST_FN="$fn_name" fi } ## # Resolves ":" for a test function and writes it into the # global _BASHUNIT_TEST_LOCATION, using `declare -F` under `extdebug` to read # the definition line. Falls back to just the file path when the line cannot be # determined. Bash 3.0+ compatible. Writes a global slot (no extra subshell). # Arguments: $1 test file, $2 function name ## function bashunit::runner::resolve_test_location() { local test_file=$1 local fn_name=$2 # Enable extdebug only inside the command-substitution subshell so it never # leaks into the parent shell — globally toggling extdebug interferes with # `set -e`/DEBUG-trap behavior under --strict. local def line="" def="$(shopt -s extdebug; declare -F "$fn_name" 2>/dev/null)" || true # `declare -F` (with extdebug) prints " ". if [ -n "$def" ]; then line=${def#* } line=${line%% *} fi if [ -n "$line" ]; then _BASHUNIT_TEST_LOCATION="${test_file}:${line}" else _BASHUNIT_TEST_LOCATION="$test_file" fi } # Writes the interpolated test-function name into _BASHUNIT_RUNNER_INTERP_OUT. # Arguments: $1 fn_name, $@ test arguments function bashunit::runner::apply_interpolated_title() { local fn_name=$1 shift # Only "::N::"-style names interpolate; skip the capture fork for the rest. case "$fn_name" in *::*) ;; *) bashunit::state::reset_current_test_interpolated_function_name _BASHUNIT_RUNNER_INTERP_OUT=$fn_name return ;; esac local interpolated interpolated="$(bashunit::helper::interpolate_function_name "$fn_name" "$@")" if [ "$interpolated" != "$fn_name" ]; then bashunit::state::set_current_test_interpolated_function_name "$interpolated" else bashunit::state::reset_current_test_interpolated_function_name fi _BASHUNIT_RUNNER_INTERP_OUT=$interpolated } # Hot-path result helpers below return their value via a dedicated global slot # (`_BASHUNIT_RUNNER_*_OUT`) instead of stdout. This avoids the per-test # `$(...)` subshell capture that dominated the result-parsing hot path. Callers # invoke the helper and immediately read the slot: # # bashunit::runner::extract_subshell_type "$subshell_output" # type=$_BASHUNIT_RUNNER_TYPE_OUT # # A dedicated slot per helper (rather than one shared slot) means nested or # adjacent calls cannot clobber each other and callers don't need to copy out # before every other helper runs. _BASHUNIT_RUNNER_FIELD_OUT="" _BASHUNIT_RUNNER_TOTAL_OUT="" _BASHUNIT_RUNNER_TYPE_OUT="" _BASHUNIT_RUNNER_OUTPUT_OUT="" _BASHUNIT_RUNNER_INTERP_OUT="" _BASHUNIT_RUNNER_COUNTS_FAILED_OUT=0 _BASHUNIT_RUNNER_COUNTS_PASSED_OUT=0 _BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT=0 _BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT=0 _BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT=0 _BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT=0 _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="" # Suffix appended to a passed-test line when it only passed after retrying. _BASHUNIT_RETRY_NOTE="" # Writes the value of an encoded field (##KEY=value##) into _BASHUNIT_RUNNER_FIELD_OUT. # Arguments: $1 test_execution_result, $2 key function bashunit::runner::extract_encoded_field() { local test_execution_result=$1 local key=$2 local marker="##${key}=" case "$test_execution_result" in *"$marker"*) local rest="${test_execution_result#*"$marker"}" _BASHUNIT_RUNNER_FIELD_OUT="${rest%%##*}" ;; *) _BASHUNIT_RUNNER_FIELD_OUT="" ;; esac } # Writes the sum of all ASSERTIONS_* counters into _BASHUNIT_RUNNER_TOTAL_OUT. # Arguments: $1 test_execution_result function bashunit::runner::compute_total_assertions() { local test_execution_result=$1 local failed passed skipped incomplete snapshot failed="${test_execution_result##*##ASSERTIONS_FAILED=}" failed="${failed%%##*}" passed="${test_execution_result##*##ASSERTIONS_PASSED=}" passed="${passed%%##*}" skipped="${test_execution_result##*##ASSERTIONS_SKIPPED=}" skipped="${skipped%%##*}" incomplete="${test_execution_result##*##ASSERTIONS_INCOMPLETE=}" incomplete="${incomplete%%##*}" snapshot="${test_execution_result##*##ASSERTIONS_SNAPSHOT=}" snapshot="${snapshot%%##*}" local total total=$((${failed:-0} + ${passed:-0} + ${skipped:-0})) total=$((total + ${incomplete:-0} + ${snapshot:-0})) _BASHUNIT_RUNNER_TOTAL_OUT=$total } # Writes the subshell type marker (text inside leading [...]) into _BASHUNIT_RUNNER_TYPE_OUT. # Arguments: $1 subshell_output function bashunit::runner::extract_subshell_type() { local subshell_output=$1 local type="${subshell_output%%]*}" _BASHUNIT_RUNNER_TYPE_OUT="${type#[}" } # Writes the subshell output (minus the leading [type] marker, with embedded # status markers replaced by newlines) into _BASHUNIT_RUNNER_OUTPUT_OUT. # Arguments: $1 subshell_output function bashunit::runner::format_subshell_output() { local subshell_output=$1 local line="${subshell_output#*]}" line=${line//\[failed\]/$'\n'} line=${line//\[skipped\]/$'\n'} line=${line//\[incomplete\]/$'\n'} _BASHUNIT_RUNNER_OUTPUT_OUT=$line } ## # Appends a profiling record (duration, test name, file) to PROFILE_OUTPUT_PATH. # Uses a tab-separated, append-only line so it aggregates correctly across the # subshells spawned by parallel runs. # Arguments: $1 duration (ms), $2 test name, $3 test file ## function bashunit::runner::record_profile() { local duration=$1 local test_name=$2 local test_file=$3 printf '%s\t%s\t%s\n' "$duration" "$test_name" "$test_file" >>"$PROFILE_OUTPUT_PATH" } # Writes the detected runtime-error message (empty when none) into # _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT. Return-slot form avoids a per-test fork # on the hot path (#764). # Arguments: $1 runtime_output function bashunit::runner::detect_runtime_error() { local runtime_output=$1 _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" case "$runtime_output" in *"command not found"* | *"unbound variable"* | *"permission denied"* | \ *"no such file or directory"* | *"syntax error"* | *"bad substitution"* | \ *"division by 0"* | *"cannot allocate memory"* | *"bad file descriptor"* | \ *"segmentation fault"* | *"illegal option"* | *"argument list too long"* | \ *"readonly variable"* | *"missing keyword"* | *"killed"* | \ *"cannot execute binary file"* | *"invalid arithmetic operator"* | \ *"ambiguous redirect"* | *"integer expression expected"* | \ *"too many arguments"* | *"value too great"* | \ *"not a valid identifier"* | *"unexpected EOF"*) local runtime_error="${runtime_output#*: }" _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="${runtime_error//$'\n'/}" ;; esac } ## # Maps a process exit code to a human-readable description when it indicates the # test was killed by a signal (128 + signal) or timed out. Returns an empty # string for ordinary exit codes. Bash 3.0+ compatible. # Arguments: $1 exit code ## function bashunit::runner::classify_kill_signal() { local code=$1 case "$code" in 124) printf 'Timed out (killed by `timeout`)' ;; 130) printf 'Interrupted (SIGINT)' ;; 137) printf 'Killed (SIGKILL — out of memory or forced termination)' ;; 143) printf 'Terminated (SIGTERM — e.g. a timeout)' ;; *) # Generic "killed by signal N" for other 128+N codes (signals 1..64) case "$code" in '' | *[!0-9]*) return 0 ;; esac if [ "$code" -gt 128 ] && [ "$code" -le 192 ]; then printf 'Killed by signal %s' "$((code - 128))" fi ;; esac } function bashunit::runner::print_verbose_test_summary() { local test_file=$1 local fn_name=$2 local duration=$3 local test_execution_result=$4 if bashunit::env::is_simple_output_enabled; then echo "" fi printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '=' printf "%s\n" "File: $test_file" printf "%s\n" "Function: $fn_name" printf "%s\n" "Duration: $duration ms" local raw_text=${test_execution_result%%##ASSERTIONS_*} [ -n "$raw_text" ] && printf "%s" "Raw text: $raw_text" printf "%s\n" "##ASSERTIONS_${test_execution_result#*##ASSERTIONS_}" printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '-' } # Returns 0 when this Bash supports `wait -n` (Bash 4.3+), 1 otherwise. function bashunit::runner::_supports_wait_n() { local major="${BASH_VERSINFO[0]:-0}" local minor="${BASH_VERSINFO[1]:-0}" if [ "$major" -gt 4 ]; then return 0 fi if [ "$major" -eq 4 ] && [ "$minor" -ge 3 ]; then return 0 fi return 1 } _BASHUNIT_RUNNER_RUNNING_JOBS_OUT=0 # Counts running background jobs into _BASHUNIT_RUNNER_RUNNING_JOBS_OUT. `jobs -pr` # still needs one command substitution, but the line count is pure-bash, so this # drops the extra `wc` fork per poll iteration on the parallel hot path (#761). function bashunit::runner::_count_running_jobs() { local running running=$(jobs -pr) if [ -z "$running" ]; then _BASHUNIT_RUNNER_RUNNING_JOBS_OUT=0 return fi local newlines="${running//[!$'\n']/}" _BASHUNIT_RUNNER_RUNNING_JOBS_OUT=$((${#newlines} + 1)) } function bashunit::runner::wait_for_job_slot() { local max_jobs="${BASHUNIT_PARALLEL_JOBS:-0}" if [ "$max_jobs" -le 0 ]; then return 0 fi if bashunit::runner::_supports_wait_n; then # Bash 4.3+: block until any child exits. No polling, no sleep latency. bashunit::runner::_count_running_jobs while [ "$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT" -ge "$max_jobs" ]; do wait -n 2>/dev/null || break bashunit::runner::_count_running_jobs done return 0 fi # Bash 3.x fallback: adaptive poll starting at 50ms, growing to 200ms to # reduce `jobs -r` overhead on long-running tests while staying responsive. local delay="0.05" local iterations=0 while true; do bashunit::runner::_count_running_jobs if [ "$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT" -lt "$max_jobs" ]; then break fi sleep "$delay" iterations=$((iterations + 1)) if [ "$iterations" -eq 4 ]; then delay="0.1" elif [ "$iterations" -eq 20 ]; then delay="0.2" fi done } function bashunit::runner::load_test_files() { local filter=$1 local tag_filter="${2:-}" local exclude_tag_filter="${3:-}" shift 3 local IFS=$' \t\n' local -a files files=("$@") local -a scripts_ids=() local scripts_ids_count=0 # Randomize file execution order (deterministic for the resolved seed). if bashunit::env::is_random_order_enabled; then local -a _shuffled_files=() local _sf while IFS= read -r _sf; do [ -n "$_sf" ] && _shuffled_files[${#_shuffled_files[@]}]=$_sf done < <(printf '%s\n' "${files[@]+"${files[@]}"}" | bashunit::math::shuffle "$(bashunit::env::seed)") files=("${_shuffled_files[@]+"${_shuffled_files[@]}"}") fi bashunit::runner::sync_coverage_flag # Initialize coverage tracking if enabled if [ "$_BASHUNIT_COVERAGE_ON" = 1 ]; then # Auto-discover coverage paths if not explicitly set if [ -z "$BASHUNIT_COVERAGE_PATHS" ]; then BASHUNIT_COVERAGE_PATHS=$(bashunit::coverage::auto_discover_paths "${files[@]}") # Fallback: if auto-discovery yields no paths, track the src/ folder if [ -z "$BASHUNIT_COVERAGE_PATHS" ]; then BASHUNIT_COVERAGE_PATHS="src/" fi fi bashunit::coverage::init fi local test_file for test_file in "${files[@]+"${files[@]}"}"; do if [ ! -f "$test_file" ]; then continue fi unset BASHUNIT_CURRENT_TEST_ID bashunit::helper::generate_id "${test_file}" export BASHUNIT_CURRENT_SCRIPT_ID="$_BASHUNIT_HELPER_ID_OUT" scripts_ids[scripts_ids_count]="${BASHUNIT_CURRENT_SCRIPT_ID}" scripts_ids_count=$((scripts_ids_count + 1)) bashunit::internal_log "Loading file" "$test_file" # Files are sourced sequentially in this loop (parallel workers fork after), # so a fixed path in the run dir is safe: `2>` truncates it per file and the # run-dir cleanup removes it, saving a mktemp and an rm fork per file. local source_err_file source_err source_status source_err_file="$_BASHUNIT_RUN_OUTPUT_DIR/source_err" # shellcheck source=/dev/null source "$test_file" 2>"$source_err_file" source_status=$? # A test file may enable `set -euo pipefail` at its top level; sourcing # runs that in THIS shell, so a later non-zero status in the loop (e.g. a # failing set_up_before_script) would kill the whole run mid-suite with no # summary. Strictness is applied per-test in execute_test_body — reset the # runner loop to its set +euo invariant (see main.sh exec_tests) (#836). set +euo pipefail source_err="" if [ -s "$source_err_file" ]; then source_err="$(cat "$source_err_file")" fi # A non-zero source status, or a syntax-error line on stderr, means the file # failed to load. Match the captured stderr with `case` (no grep fork). local source_failed=false if [ "$source_status" -ne 0 ]; then source_failed=true else case "$source_err" in *"syntax error"* | *"unexpected EOF"*) source_failed=true ;; esac fi if [ "$source_failed" = true ]; then local message="$source_err" [ -z "$message" ] && message="Failed to source '$test_file' (exit $source_status)" bashunit::runner::record_file_hook_failure \ "source" "$test_file" "$message" 1 true bashunit::runner::clean_set_up_and_tear_down_after_script bashunit::runner::restore_workdir continue fi # Update function cache after sourcing new test file (compgen is a builtin) _BASHUNIT_CACHED_ALL_FUNCTIONS=$(compgen -A function) # Check if any tests match the filter before rendering header or running hooks local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") local functions_for_script functions_for_script=$(bashunit::runner::functions_for_script "$test_file" "$filtered_functions") # Full pre-tag/rerun list: these are unset once the file has been # processed, whatever subset actually runs (#829). local _script_fns_to_clean="$functions_for_script" # Apply tag filtering to the early check as well if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then bashunit::helper::build_tags_map "$test_file" local _early_filtered="" local _early_fn for _early_fn in $functions_for_script; do bashunit::helper::tags_for_function "$_early_fn" if bashunit::helper::function_matches_tags "$_BASHUNIT_TAGS_OUT" "$tag_filter" "$exclude_tag_filter"; then _early_filtered="$_early_filtered $_early_fn" fi done functions_for_script="${_early_filtered# }" fi # Replay filtering: keep only the functions recorded as failing last run. if bashunit::rerun::is_enabled && bashunit::rerun::has_entries; then functions_for_script=$(bashunit::rerun::filter_functions "$test_file" "$functions_for_script") fi if [ -z "$functions_for_script" ]; then bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" bashunit::runner::clean_set_up_and_tear_down_after_script bashunit::runner::restore_workdir continue fi # Render header BEFORE set_up_before_script so user sees activity immediately bashunit::runner::render_running_file_header "$test_file" # Call hook directly (not with `if !`) to preserve errexit behavior inside the hook bashunit::runner::run_set_up_before_script "$test_file" local setup_before_script_status=$? if [ $setup_before_script_status -ne 0 ]; then # Count the test functions that couldn't run due to set_up_before_script # failure and add them as failed (minus 1 since the hook failure already # counts as 1). Use this file's own function list — scanning the cached # ALL-functions set would also count fns left over from earlier files # and inflate the totals (#836). if [ -n "$functions_for_script" ]; then # Bash 3.0 compatible: separate declaration and assignment for arrays local functions_to_run # shellcheck disable=SC2206 functions_to_run=($functions_for_script) local additional_failures=$((${#functions_to_run[@]} - 1)) local i for ((i = 0; i < additional_failures; i++)); do bashunit::state::add_tests_failed done fi # Same cleanup as the success path: without it the file's test functions # leak into the next iteration's counts and the main shell (#829, #836). bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" bashunit::runner::clean_set_up_and_tear_down_after_script if ! bashunit::parallel::is_enabled; then bashunit::cleanup_script_temp_files fi bashunit::runner::restore_workdir continue fi local _cached_fns="$functions_for_script" if bashunit::parallel::is_enabled; then bashunit::runner::wait_for_job_slot bashunit::runner::call_test_functions \ "$test_file" "$filter" "$tag_filter" \ "$exclude_tag_filter" "$_cached_fns" 2>/dev/null & else bashunit::runner::call_test_functions \ "$test_file" "$filter" "$tag_filter" \ "$exclude_tag_filter" "$_cached_fns" fi bashunit::runner::run_tear_down_after_script "$test_file" bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" bashunit::runner::clean_set_up_and_tear_down_after_script if ! bashunit::parallel::is_enabled; then bashunit::cleanup_script_temp_files fi bashunit::internal_log "Finished file" "$test_file" bashunit::runner::restore_workdir done if bashunit::parallel::is_enabled; then wait bashunit::runner::spinner & local spinner_pid=$! bashunit::parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE" # Kill the spinner once the aggregation finishes disown "$spinner_pid" 2>/dev/null || true kill "$spinner_pid" 2>/dev/null || true printf "\r \r" # Clear the spinner output local script_id for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}" bashunit::cleanup_script_temp_files done fi } function bashunit::runner::load_bench_files() { local filter=$1 shift local IFS=$' \t\n' local -a files files=("$@") local bench_file for bench_file in "${files[@]+"${files[@]}"}"; do [ -f "$bench_file" ] || continue unset BASHUNIT_CURRENT_TEST_ID bashunit::helper::generate_id "${bench_file}" export BASHUNIT_CURRENT_SCRIPT_ID="$_BASHUNIT_HELPER_ID_OUT" # shellcheck source=/dev/null source "$bench_file" # Reset the loop's shell-mode invariant; a bench file may set -euo at top # level and sourcing runs that in this shell (see the test loop) (#836). set +euo pipefail # Update function cache after sourcing new bench file (compgen is a builtin) _BASHUNIT_CACHED_ALL_FUNCTIONS=$(compgen -A function) # Call hook directly (not with `if !`) to preserve errexit behavior inside the hook bashunit::runner::run_set_up_before_script "$bench_file" local setup_before_script_status=$? if [ $setup_before_script_status -ne 0 ]; then # Count the bench functions that couldn't run due to set_up_before_script failure # and add them as failed (minus 1 since the hook failure already counts as 1) local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run "bench" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") if [ -n "$filtered_functions" ]; then # Bash 3.0 compatible: separate declaration and assignment for arrays local functions_to_run # shellcheck disable=SC2206 functions_to_run=($filtered_functions) local additional_failures=$((${#functions_to_run[@]} - 1)) local i for ((i = 0; i < additional_failures; i++)); do bashunit::state::add_tests_failed done fi bashunit::runner::clean_set_up_and_tear_down_after_script bashunit::cleanup_script_temp_files bashunit::runner::restore_workdir continue fi bashunit::runner::call_bench_functions "$bench_file" "$filter" bashunit::runner::run_tear_down_after_script "$bench_file" bashunit::runner::clean_set_up_and_tear_down_after_script bashunit::cleanup_script_temp_files bashunit::runner::restore_workdir done } function bashunit::runner::spinner() { # Only show spinner when output is to a terminal if [ ! -t 1 ]; then # Not a terminal, just wait silently while true; do sleep 1; done return fi # Don't show spinner in no-progress mode if bashunit::env::is_no_progress_enabled; then while true; do sleep 1; done return fi if bashunit::env::is_simple_output_enabled; then printf "\n" fi local delay=0.1 local spin_chars="|/-\\" while true; do local i for ((i = 0; i < ${#spin_chars}; i++)); do printf "\r%s" "${spin_chars:$i:1}" sleep "$delay" done done } function bashunit::runner::functions_for_script() { local script="$1" local all_fn_names="$2" # Resolve " " for the given names, enabling extdebug only # inside the capture subshell so the caller's setting is untouched. local declarations # shellcheck disable=SC2086 declarations=$( shopt -s extdebug declare -F $all_fn_names 2>/dev/null ) # Keep the functions defined in this script, insertion-sorted by definition # line. Pure bash: the old `awk | sort | awk` pipeline cost three forks and # ran twice per file, while a file's function list is small (tens of names). local -a fns=() local -a fn_lines=() local count=0 local name line file i while read -r name line file; do [ "$file" = "$script" ] || continue i=$count while [ "$i" -gt 0 ] && [ "${fn_lines[i - 1]}" -gt "$line" ]; do fns[i]=${fns[i - 1]} fn_lines[i]=${fn_lines[i - 1]} i=$((i - 1)) done fns[i]=$name fn_lines[i]=$line count=$((count + 1)) done </dev/null; then # Check if args has elements after eval args_count=0 local _tmp arg for _tmp in ${args+"${args[@]}"}; do args_count=$((args_count + 1)); done if [ "$args_count" -gt 0 ]; then # Successfully parsed - remove sentinel if present local last_idx=$((args_count - 1)) if [ -z "${args[$last_idx]}" ]; then unset 'args[$last_idx]' fi # Print args and return early for arg in "${args[@]+"${args[@]}"}"; do encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" done return fi fi # Fallback: parse args from the input string into an array, respecting quotes and escapes local i for ((i = 0; i < ${#input}; i++)); do local char="${input:$i:1}" if [ "$escaped" = true ]; then case "$char" in t) current_arg="$current_arg"$'\t' ;; n) current_arg="$current_arg"$'\n' ;; *) current_arg="$current_arg$char" ;; esac escaped=false elif [ "$char" = "\\" ]; then escaped=true elif [ "$in_quotes" = false ]; then case "$char" in "$") # Handle $'...' syntax if [ "${input:$i:2}" = "$'" ]; then in_quotes=true had_quotes=true quote_char="'" # Skip the $ i=$((i + 1)) else current_arg="$current_arg$char" fi ;; "'" | '"') in_quotes=true had_quotes=true quote_char="$char" ;; " " | $'\t') # Add if non-empty OR if was quoted (to preserve empty quoted strings like '') if [ -n "$current_arg" ] || [ "$had_quotes" = true ]; then args[args_count]="$current_arg" args_count=$((args_count + 1)) fi current_arg="" had_quotes=false ;; *) current_arg="$current_arg$char" ;; esac elif [ "$char" = "$quote_char" ]; then in_quotes=false quote_char="" else current_arg="$current_arg$char" fi done args[args_count]="$current_arg" args_count=$((args_count + 1)) # Remove all trailing empty strings while [ "$args_count" -gt 0 ]; do local last_idx=$((args_count - 1)) if [ -z "${args[$last_idx]}" ]; then unset 'args[$last_idx]' args_count=$((args_count - 1)) else break fi done # Print one arg per line to stdout, base64-encoded to preserve newlines in the data local arg for arg in ${args+"${args[@]}"}; do encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" done } function bashunit::runner::call_test_functions() { local script="$1" local filter="$2" local tag_filter="${3:-}" local exclude_tag_filter="${4:-}" local cached_functions="${5:-}" local IFS=$' \t\n' local -a functions_to_run=() local functions_to_run_count=0 if [ -n "$cached_functions" ]; then # Use pre-computed function list from load_test_files (already tag-filtered) local _fn for _fn in $cached_functions; do [ -z "$_fn" ] && continue functions_to_run[functions_to_run_count]="$_fn" functions_to_run_count=$((functions_to_run_count + 1)) done else # Fallback: compute function list (for direct calls without cache) local prefix="test" local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run \ "$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") local _fn while IFS= read -r _fn; do [ -z "$_fn" ] && continue functions_to_run[functions_to_run_count]="$_fn" functions_to_run_count=$((functions_to_run_count + 1)) done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions") # Apply tag filtering if --tag or --exclude-tag was specified if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then bashunit::helper::build_tags_map "$script" local -a tag_filtered=() local tag_filtered_count=0 local _tf_fn for _tf_fn in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do bashunit::helper::tags_for_function "$_tf_fn" if bashunit::helper::function_matches_tags "$_BASHUNIT_TAGS_OUT" "$tag_filter" "$exclude_tag_filter"; then tag_filtered[tag_filtered_count]="$_tf_fn" tag_filtered_count=$((tag_filtered_count + 1)) fi done functions_to_run=("${tag_filtered[@]+"${tag_filtered[@]}"}") functions_to_run_count=$tag_filtered_count fi fi # Randomize function order within this file. The seed is mixed with a stable # per-file value (cksum of the path) so different files get different orders # while staying reproducible for the resolved seed. if bashunit::env::is_random_order_enabled && [ "$functions_to_run_count" -gt 1 ]; then local _base _crc _fn_seed _base=$(bashunit::env::seed) _crc=$(printf '%s' "$script" | cksum | cut -d' ' -f1) _fn_seed=$(((_base + _crc) & 2147483647)) local -a _shuffled_fns=() local _sfn while IFS= read -r _sfn; do [ -n "$_sfn" ] && _shuffled_fns[${#_shuffled_fns[@]}]=$_sfn done < <(printf '%s\n' "${functions_to_run[@]+"${functions_to_run[@]}"}" | bashunit::math::shuffle "$_fn_seed") functions_to_run=("${_shuffled_fns[@]+"${_shuffled_fns[@]}"}") functions_to_run_count=${#functions_to_run[@]} fi if [ "$functions_to_run_count" -le 0 ]; then return fi bashunit::helper::check_duplicate_functions "$script" || true local -a provider_data=() local provider_data_count=0 local -a parsed_data=() local parsed_data_count=0 # Scan the file once; per-test provider lookups below are pure-bash (#763). # The same pass also detects the no-parallel-tests opt-out (#774). bashunit::helper::build_provider_map "$script" local allow_test_parallel=true if [ "$_BASHUNIT_PROVIDER_MAP_NO_PARALLEL" = true ]; then allow_test_parallel=false fi # Pre-create the file's result dir before spawning test workers: they all # publish into it, and checking `[ -d ]` inside a worker races its siblings # (every worker would still pay the mkdir fork). if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then local _suite_base="${script##*/}" mkdir -p "${TEMP_DIR_PARALLEL_TEST_SUITE}/${_suite_base%.sh}" 2>/dev/null || true fi for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then break fi # No data provider found: run once without forking to capture provider output. bashunit::helper::provider_for_function "$fn_name" if [ -z "$_BASHUNIT_PROVIDER_FN_OUT" ]; then if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then bashunit::runner::wait_for_job_slot bashunit::runner::run_test "$script" "$fn_name" & else bashunit::runner::run_test "$script" "$fn_name" fi unset -v fn_name continue fi provider_data=() provider_data_count=0 local line while IFS=" " read -r line; do [ -z "$line" ] && continue provider_data[provider_data_count]="$line" provider_data_count=$((provider_data_count + 1)) done <<<"$(bashunit::helper::execute_function_if_exists "$_BASHUNIT_PROVIDER_FN_OUT")" # Execute the test function for each line of data local data for data in "${provider_data[@]+"${provider_data[@]}"}"; do parsed_data=() parsed_data_count=0 local line while IFS= read -r line; do [ -z "$line" ] && continue parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")" parsed_data_count=$((parsed_data_count + 1)) done <<<"$(bashunit::runner::parse_data_provider_args "$data")" if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then bashunit::runner::wait_for_job_slot bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} & else bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} fi done unset -v fn_name done # Wait for all parallel tests within this file to complete if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then wait fi } function bashunit::runner::call_bench_functions() { local script="$1" local filter="$2" local IFS=$' \t\n' local prefix="bench" # Use cached function names for better performance local filtered_functions filtered_functions=$(bashunit::helper::get_functions_to_run \ "$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") local -a functions_to_run=() local functions_to_run_count=0 local _fn while IFS= read -r _fn; do [ -z "$_fn" ] && continue functions_to_run[functions_to_run_count]="$_fn" functions_to_run_count=$((functions_to_run_count + 1)) done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions") if [ "$functions_to_run_count" -le 0 ]; then return fi if bashunit::env::is_bench_mode_enabled; then bashunit::runner::render_running_file_header "$script" fi local fn_name for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do read -r revs its max_ms <<<"$(bashunit::benchmark::parse_annotations "$fn_name" "$script")" bashunit::benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" unset -v fn_name done if ! bashunit::env::is_simple_output_enabled; then echo "" fi } function bashunit::runner::render_running_file_header() { local script="$1" local force="${2:-false}" bashunit::internal_log "Running file" "$script" if [ "$force" != true ] && bashunit::parallel::is_enabled; then return fi # Suppress file headers in failures-only mode if bashunit::env::is_failures_only_enabled; then return fi # Suppress file headers in no-progress mode if bashunit::env::is_no_progress_enabled; then return fi if bashunit::env::is_tap_output_enabled; then printf "# %s\n" "$script" elif ! bashunit::env::is_simple_output_enabled; then if bashunit::env::is_verbose_enabled; then printf "\n${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" "Running $script" else printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" "Running $script" fi elif bashunit::env::is_verbose_enabled; then printf "\n\n${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}" "Running $script" fi } # Result slots for the timeout-aware execution path (see run_with_timeout). _BASHUNIT_RUNNER_EXEC_OUT="" _BASHUNIT_RUNNER_TIMED_OUT="false" ## # Runs a single test inside the capture subshell: sets up the EXIT trap that # encodes assertion counts/exit code, runs set_up, applies the shell mode and # finally invokes the test function. Meant to be called from a subshell (either # the `$(...)` capture or a backgrounded job), so its `set`/`trap`/`exit` calls # stay isolated. Emits the test stdout (with stderr merged) followed by the # encoded context from cleanup_on_exit. # Arguments: $1 test file, $2 function name, $@ test args ## function bashunit::runner::execute_test_body() { local test_file=$1 shift local fn_name=$1 shift # Save subshell stdout to FD 5 so the EXIT trap can restore it. # When set -e kills the subshell during a redirected block in # execute_test_hook, the redirect leaks into the EXIT trap, # causing export_subshell_context output to be lost. exec 5>&1 # shellcheck disable=SC2064 trap "exit_code=\$?; bashunit::runner::cleanup_on_exit \"$test_file\" \"\$exit_code\"" EXIT bashunit::state::initialize_assertions_count if bashunit::env::is_login_shell_enabled; then bashunit::runner::source_login_shell_profiles fi # Enable coverage tracking early to include set_up/tear_down hooks if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::enable_trap fi # Run set_up and capture exit code without || to preserve errexit behavior # shellcheck disable=SC2030 _BASHUNIT_SETUP_COMPLETED=false local setup_exit_code=0 bashunit::runner::run_set_up "$test_file" setup_exit_code=$? _BASHUNIT_SETUP_COMPLETED=true if [ $setup_exit_code -ne 0 ]; then exit $setup_exit_code fi # Apply shell mode setting for test execution if bashunit::env::is_strict_mode_enabled; then set -eu # Bash 3.0 ships a broken pipefail; only enable it where it is reliable. if bashunit::runner::_supports_reliable_pipefail; then set -o pipefail else set +o pipefail fi else set +euo pipefail fi # 2>&1: Redirects the std-error (FD 2) to the std-output (FD 1). # points to the original std-output. "$fn_name" "$@" 2>&1 } ## # Prints an encoded subshell result for a test that timed out: empty assertion # counters and exit code 124 (the conventional "timed out" code, already mapped # by classify_kill_signal). The empty TEST_HOOK_MESSAGE/TITLE/OUTPUT fields would # base64-encode to an empty string anyway, so the line is emitted directly rather # than mutating the shared _BASHUNIT_* globals (it mirrors the layout produced by # bashunit::state::export_subshell_context). Bash 3.0+ compatible. ## function bashunit::runner::build_timeout_result() { printf '%s' "##ASSERTIONS_FAILED=0##ASSERTIONS_PASSED=0##ASSERTIONS_SKIPPED=0\ ##ASSERTIONS_INCOMPLETE=0##ASSERTIONS_SNAPSHOT=0##TEST_EXIT_CODE=124\ ##TEST_HOOK_FAILURE=##TEST_HOOK_MESSAGE=##TEST_TITLE=##TEST_OUTPUT=##" } ## # Runs the test body with a watchdog that kills it after BASHUNIT_TEST_TIMEOUT # seconds. The body runs as a backgrounded job in its own process group (set -m) # so the watchdog can SIGTERM/SIGKILL the whole tree — a hanging test usually # blocks in a child process, which signalling the subshell alone cannot reach. # Writes the captured result to _BASHUNIT_RUNNER_EXEC_OUT and "true"/"false" to # _BASHUNIT_RUNNER_TIMED_OUT. Bash 3.0+ compatible (validated on Bash 3.2). # Arguments: $1 test file, $2 function name, $@ test args ## function bashunit::runner::run_with_timeout() { local test_file=$1 shift local fn_name=$1 shift local secs secs=$(bashunit::env::test_timeout_secs) # NOTE: these must NOT use bashunit::temp_file — that prefixes the current # test id, and cleanup_on_exit (run inside the test subshell) would unlink # them via cleanup_testcase_temp_files before we read them back here. local tmp_dir="${BASHUNIT_TEMP_DIR:-${TMPDIR:-/tmp}}" local out_file marker_file out_file="$("$MKTEMP" "$tmp_dir/bashunit_timeout_out.XXXXXXX")" marker_file="$("$MKTEMP" "$tmp_dir/bashunit_timeout_marker.XXXXXXX")" rm -f "$marker_file" # Both jobs run in their own process group (set -m) so each can be killed as a # whole tree. The body MUST run in an explicit ( ) subshell: a backgrounded { } # group does not run its EXIT trap on normal completion, which would drop the # encoded assertion context. The watchdog's fds are detached from the caller so # a lingering `sleep` can never hold a captured stdout pipe open. set -m (bashunit::runner::execute_test_body "$test_file" "$fn_name" "$@") >"$out_file" 2>&1 & local test_pid=$! ( sleep "$secs" # Only a still-running test can have timed out. Without this guard a watchdog # that outlived a missed teardown (see below) would mark an already-finished # fast test as timed out. kill -0 "$test_pid" 2>/dev/null || exit 0 : >"$marker_file" kill -TERM -"$test_pid" 2>/dev/null sleep 0.3 kill -KILL -"$test_pid" 2>/dev/null ) /dev/null 2>&1 & local watchdog_pid=$! set +m wait "$test_pid" 2>/dev/null # Stop the watchdog by its pid AND its group. `set -m` does not reliably make a # backgrounded subshell a group leader in a non-interactive shell, so the # group-only kill intermittently misses, letting the watchdog sleep its full # timeout and fire against a test that already passed. The direct-pid signal is # always deliverable; the group signal also reaps the `sleep` child. kill -TERM "$watchdog_pid" 2>/dev/null kill -TERM -"$watchdog_pid" 2>/dev/null wait "$watchdog_pid" 2>/dev/null if [ -f "$marker_file" ]; then _BASHUNIT_RUNNER_TIMED_OUT="true" _BASHUNIT_RUNNER_EXEC_OUT="$(bashunit::runner::build_timeout_result)" else _BASHUNIT_RUNNER_TIMED_OUT="false" _BASHUNIT_RUNNER_EXEC_OUT="$(cat "$out_file" 2>/dev/null)" fi rm -f "$out_file" "$marker_file" } # Per-test duration is consumed by --profile, --verbose, report files, and the # execution-time display. When none are active we can skip the clock reads, # which matters when the clock forks an interpreter (#765). function bashunit::runner::needs_test_duration() { bashunit::env::is_profile_enabled && return 0 bashunit::env::is_verbose_enabled && return 0 bashunit::reports::is_enabled && return 0 bashunit::env::is_show_execution_time_enabled && return 0 return 1 } function bashunit::runner::run_test() { local start_time=0 local test_file="$1" shift local fn_name="$1" shift bashunit::internal_log "Running test" "$fn_name" "$*" bashunit::runner::export_test_identity "$test_file" "$fn_name" bashunit::state::reset_test_title bashunit::runner::apply_interpolated_title "$fn_name" "$@" local interpolated_fn_name=$_BASHUNIT_RUNNER_INTERP_OUT local current_assertions_failed="$_BASHUNIT_ASSERTIONS_FAILED" local current_assertions_snapshot="$_BASHUNIT_ASSERTIONS_SNAPSHOT" local current_assertions_incomplete="$_BASHUNIT_ASSERTIONS_INCOMPLETE" local current_assertions_skipped="$_BASHUNIT_ASSERTIONS_SKIPPED" # (FD = File Descriptor) # Duplicate the current std-output (FD 1) and assigns it to FD 3. # This means that FD 3 now points to wherever the std-output was pointing. exec 3>&1 local test_execution_result local timed_out="false" bashunit::env::resolve_retry_count local retry_max=$_BASHUNIT_RETRY_VALIDATED local retries_used=0 local measure_duration=false bashunit::runner::needs_test_duration && measure_duration=true # Retry wraps ONLY execution: a failed attempt is judged from its encoded # result without committing, so the parse/report/counter path below still runs # exactly once (on the final attempt) and nothing is double-counted. Each fork # in --parallel retries itself before writing its single .result file. while :; do if [ "$measure_duration" = true ]; then bashunit::clock::now_to_slot start_time=$_BASHUNIT_CLOCK_NOW_OUT fi if bashunit::env::is_test_timeout_enabled; then bashunit::runner::run_with_timeout "$test_file" "$fn_name" "$@" test_execution_result="$_BASHUNIT_RUNNER_EXEC_OUT" timed_out="$_BASHUNIT_RUNNER_TIMED_OUT" else test_execution_result=$(bashunit::runner::execute_test_body "$test_file" "$fn_name" "$@") fi local attempt_runtime_output="${test_execution_result%%##ASSERTIONS_*}" bashunit::runner::detect_runtime_error "$attempt_runtime_output" local attempt_runtime_error=$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT bashunit::runner::extract_result_counts "$test_execution_result" # Mirror the commit-phase failure test exactly (runtime error, non-zero exit, # or a failed assertion); snapshot/incomplete/skipped/risky are not failures. if [ -z "$attempt_runtime_error" ] && [ "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" -eq 0 ] && [ "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" -eq 0 ]; then break fi [ "$retries_used" -ge "$retry_max" ] && break retries_used=$((retries_used + 1)) done # Closes FD 3, which was used temporarily to hold the original stdout. exec 3>&- local duration=0 if [ "$measure_duration" = true ]; then bashunit::clock::now_to_slot local end_time=$_BASHUNIT_CLOCK_NOW_OUT duration=$(((end_time - start_time) / 1000000)) fi if bashunit::env::is_profile_enabled; then bashunit::runner::record_profile "$duration" "$interpolated_fn_name" "$test_file" fi if bashunit::env::is_verbose_enabled; then bashunit::runner::print_verbose_test_summary \ "$test_file" "$fn_name" "$duration" "$test_execution_result" fi bashunit::runner::decode_subshell_output "$test_execution_result" local subshell_output=$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT if [ -n "$subshell_output" ]; then bashunit::runner::extract_subshell_type "$subshell_output" local type=$_BASHUNIT_RUNNER_TYPE_OUT bashunit::runner::format_subshell_output "$subshell_output" subshell_output=$_BASHUNIT_RUNNER_OUTPUT_OUT if ! bashunit::env::is_failures_only_enabled; then bashunit::state::print_line "$type" "$subshell_output" fi fi # Reuse the final attempt's values (the loop always runs at least once and # its locals persist in this function scope), instead of recomputing and # forking detect_runtime_error a second time (#764). local runtime_output=$attempt_runtime_output local runtime_error=$attempt_runtime_error # parse_result accumulates _BASHUNIT_TEST_EXIT_CODE; reset it so each test's # exit code is read in isolation (a non-zero/timed-out test must not poison # the next one). _BASHUNIT_TEST_EXIT_CODE=0 bashunit::runner::parse_result "$fn_name" "$test_execution_result" "$@" local test_exit_code="$_BASHUNIT_TEST_EXIT_CODE" bashunit::runner::compute_total_assertions "$test_execution_result" local total_assertions=$_BASHUNIT_RUNNER_TOTAL_OUT bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_TITLE" local encoded_test_title=$_BASHUNIT_RUNNER_FIELD_OUT bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_HOOK_FAILURE" local hook_failure=$_BASHUNIT_RUNNER_FIELD_OUT bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_HOOK_MESSAGE" local encoded_hook_message=$_BASHUNIT_RUNNER_FIELD_OUT local test_title="" [ -n "$encoded_test_title" ] && test_title="$(bashunit::helper::decode_base64 "$encoded_test_title")" local hook_message="" [ -n "$encoded_hook_message" ] && hook_message="$(bashunit::helper::decode_base64 "$encoded_hook_message")" bashunit::set_test_title "$test_title" bashunit::helper::normalize_test_function_name_to_slot "$fn_name" "$interpolated_fn_name" local label=$_BASHUNIT_HELPER_NORMALIZED_OUT bashunit::state::reset_test_title bashunit::state::reset_current_test_interpolated_function_name local failure_label="$label" local failure_function="$fn_name" if [ -n "$hook_failure" ]; then bashunit::helper::normalize_test_function_name_to_slot "$hook_failure" failure_label=$_BASHUNIT_HELPER_NORMALIZED_OUT failure_function="$hook_failure" fi if [ -n "$runtime_error" ] || [ "$test_exit_code" -ne 0 ]; then bashunit::state::add_tests_failed bashunit::rerun::record "$test_file" "$fn_name" local error_message="$runtime_error" if [ -n "$hook_failure" ] && [ -n "$hook_message" ]; then error_message="$hook_message" elif [ -z "$error_message" ] && [ -n "$hook_message" ]; then error_message="$hook_message" fi # When the test was killed by a signal (or timed out), replace an empty or # generic "Killed" message with a specific cause. if [ -z "$hook_failure" ]; then local kill_message kill_message=$(bashunit::runner::classify_kill_signal "$test_exit_code") if [ -n "$kill_message" ]; then case "$error_message" in '' | *[Kk]illed* | *[Tt]erminated*) error_message="$kill_message" ;; esac fi fi # A test that exceeded BASHUNIT_TEST_TIMEOUT gets a clear, specific message. if [ "$timed_out" = "true" ]; then error_message="Test timed out after $(bashunit::env::test_timeout_secs)s" fi bashunit::console_results::print_error_test "$failure_function" "$error_message" "$runtime_output" bashunit::reports::add_test_failed "$test_file" "$failure_label" "$duration" "$total_assertions" "$error_message" bashunit::runner::write_failure_result_output "$test_file" "$failure_function" "$error_message" "$runtime_output" bashunit::internal_log "Test error" "$failure_label" "$error_message" if bashunit::env::is_stop_on_failure_enabled; then if bashunit::parallel::is_enabled; then bashunit::parallel::mark_stop_on_failure else exit "$EXIT_CODE_STOP_ON_FAILURE" fi fi return fi if [ "$current_assertions_failed" != "$_BASHUNIT_ASSERTIONS_FAILED" ]; then bashunit::state::add_tests_failed bashunit::rerun::record "$test_file" "$fn_name" bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$subshell_output" local assertion_runtime_output assertion_runtime_output="$( bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$subshell_output" )" bashunit::runner::write_failure_result_output \ "$test_file" "$fn_name" "$subshell_output" "$assertion_runtime_output" bashunit::internal_log "Test failed" "$label" if bashunit::env::is_stop_on_failure_enabled; then if bashunit::parallel::is_enabled; then bashunit::parallel::mark_stop_on_failure else exit "$EXIT_CODE_STOP_ON_FAILURE" fi fi return fi if [ "$current_assertions_snapshot" != "$_BASHUNIT_ASSERTIONS_SNAPSHOT" ]; then bashunit::state::add_tests_snapshot # In failures-only mode, suppress snapshot test output if ! bashunit::env::is_failures_only_enabled; then bashunit::console_results::print_snapshot_test "$label" fi bashunit::reports::add_test_snapshot "$test_file" "$label" "$duration" "$total_assertions" bashunit::internal_log "Test snapshot" "$label" return fi if [ "$current_assertions_incomplete" != "$_BASHUNIT_ASSERTIONS_INCOMPLETE" ]; then bashunit::state::add_tests_incomplete bashunit::reports::add_test_incomplete "$test_file" "$label" "$duration" "$total_assertions" bashunit::runner::write_incomplete_result_output "$test_file" "$fn_name" "$subshell_output" bashunit::internal_log "Test incomplete" "$label" return fi if [ "$current_assertions_skipped" != "$_BASHUNIT_ASSERTIONS_SKIPPED" ]; then bashunit::state::add_tests_skipped bashunit::reports::add_test_skipped "$test_file" "$label" "$duration" "$total_assertions" bashunit::runner::write_skipped_result_output "$test_file" "$fn_name" "$subshell_output" bashunit::internal_log "Test skipped" "$label" return fi # Check for risky test (zero assertions) if [ "$total_assertions" -eq 0 ]; then if bashunit::env::is_fail_on_risky_enabled; then local risky_msg="Test has no assertions (risky)" bashunit::state::add_tests_failed bashunit::rerun::record "$test_file" "$fn_name" bashunit::console_results::print_error_test "$fn_name" "$risky_msg" bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$risky_msg" bashunit::runner::write_failure_result_output "$test_file" "$fn_name" "$risky_msg" bashunit::internal_log "Test failed (risky)" "$label" if bashunit::env::is_stop_on_failure_enabled; then if bashunit::parallel::is_enabled; then bashunit::parallel::mark_stop_on_failure else exit "$EXIT_CODE_STOP_ON_FAILURE" fi fi return fi bashunit::state::add_tests_risky if ! bashunit::env::is_failures_only_enabled; then bashunit::console_results::print_risky_test "${label}" "$duration" fi bashunit::reports::add_test_risky "$test_file" "$label" "$duration" "$total_assertions" bashunit::runner::write_risky_result_output "$test_file" "$fn_name" bashunit::internal_log "Test risky" "$label" return fi # A test that only passed after retrying is annotated so flakiness stays visible. _BASHUNIT_RETRY_NOTE="" if [ "$retries_used" -gt 0 ]; then _BASHUNIT_RETRY_NOTE=" (retry $retries_used/$retry_max)" fi # In failures-only mode, suppress successful test output if ! bashunit::env::is_failures_only_enabled; then if [ "$fn_name" = "$interpolated_fn_name" ]; then bashunit::console_results::print_successful_test "${label}" "$duration" "$@" else bashunit::console_results::print_successful_test "${label}" "$duration" fi fi _BASHUNIT_RETRY_NOTE="" bashunit::state::add_tests_passed bashunit::reports::add_test_passed "$test_file" "$label" "$duration" "$total_assertions" bashunit::internal_log "Test passed" "$label" } function bashunit::runner::cleanup_on_exit() { local test_file="$1" local exit_code="$2" # Disable coverage trap before cleanup to avoid interference if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::disable_trap fi set +e # Detect unexpected subshell exit during set_up (Issue #611). # When 'source' of a non-existent file fails under set -eE, the ERR trap # does not fire. On macOS Bash 3.2, $? is 0 in the EXIT trap; on Linux # Bash 5.x, $? is 1. In both cases the hook failure is not recorded. # Additionally, the stdout redirect from execute_test_hook leaks into the # EXIT trap. Restore stdout from saved FD 5 so export_subshell_context # output reaches test_execution_result. # shellcheck disable=SC2031 if [ "${_BASHUNIT_SETUP_COMPLETED:-true}" != "true" ]; then exec 1>&5 if [ "$exit_code" -eq 0 ]; then exit_code=1 fi if [ -z "${_BASHUNIT_TEST_HOOK_FAILURE:-}" ]; then bashunit::state::set_test_hook_failure "set_up" bashunit::state::set_test_hook_message "Hook 'set_up' failed unexpectedly (e.g., source of non-existent file)" fi fi # Don't use || here - it disables ERR trap in the entire call chain bashunit::runner::run_tear_down "$test_file" local teardown_status=$? bashunit::runner::clear_mocks bashunit::cleanup_testcase_temp_files if [ $teardown_status -ne 0 ]; then bashunit::state::set_test_exit_code "$teardown_status" else bashunit::state::set_test_exit_code "$exit_code" fi bashunit::state::export_subshell_context } # Writes the decoded subshell output into _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT. # The empty case (a passing test with no captured output) short-circuits with # no subshell at all; only the non-empty path pays the base64 fork (#762/#764). # Arguments: $1 test_execution_result function bashunit::runner::decode_subshell_output() { local test_execution_result="$1" local test_output_base64="${test_execution_result##*##TEST_OUTPUT=}" test_output_base64="${test_output_base64%%##*}" if [ -z "$test_output_base64" ] || [ "$test_output_base64" = "_BASHUNIT_EMPTY_" ]; then _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="" return fi _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="$(bashunit::helper::decode_base64 "$test_output_base64")" } function bashunit::runner::is_simple_progress_output() { local output="$1" [ -n "$output" ] || return 1 local color for color in \ "$_BASHUNIT_COLOR_DEFAULT" \ "$_BASHUNIT_COLOR_PASSED" \ "$_BASHUNIT_COLOR_FAILED" \ "$_BASHUNIT_COLOR_SKIPPED" \ "$_BASHUNIT_COLOR_INCOMPLETE" \ "$_BASHUNIT_COLOR_SNAPSHOT" \ "$_BASHUNIT_COLOR_RISKY"; do [ -n "$color" ] && output="${output//"$color"/}" done local i local char for ((i = 0; i < ${#output}; i++)); do char="${output:$i:1}" case "$char" in "." | "F" | "S" | "I" | "N" | "R" | "E" | "?") ;; *) return 1 ;; esac done return 0 } function bashunit::runner::line_exists_in_output() { local needle="$1" local haystack="$2" local line while IFS= read -r line || [ -n "$line" ]; do [ "$line" = "$needle" ] && return 0 done <<<"$haystack" return 1 } function bashunit::runner::extract_assertion_runtime_output() { local runtime_output="$1" local rendered_assertion_output="$2" local filtered_output="" local line while IFS= read -r line || [ -n "$line" ]; do if bashunit::runner::line_exists_in_output "$line" "$rendered_assertion_output"; then continue fi if bashunit::runner::is_simple_progress_output "$line"; then continue fi [ -n "$filtered_output" ] && filtered_output="$filtered_output"$'\n' filtered_output="$filtered_output$line" done <<<"$runtime_output" runtime_output="$filtered_output" while [ -n "$runtime_output" ]; do case "$runtime_output" in *$'\n') runtime_output="${runtime_output%$'\n'}" ;; *) break ;; esac done echo "$runtime_output" } function bashunit::runner::parse_result() { local fn_name=$1 shift local execution_result=$1 shift local IFS=$' \t\n' local -a args args=("$@") if bashunit::parallel::is_enabled; then bashunit::runner::parse_result_parallel "$fn_name" "$execution_result" ${args+"${args[@]}"} else bashunit::runner::parse_result_sync "$fn_name" "$execution_result" fi } function bashunit::runner::parse_result_parallel() { local fn_name=$1 shift local execution_result=$1 shift local IFS=$' \t\n' local -a args args=("$@") # This runs once per test in every parallel worker, so avoid per-test forks: # derive the suite dir name with parameter expansion (no basename), only # mkdir when the dir is missing (first test of the file wins the race, # `-p` makes the losers no-ops), and skip arg sanitizing entirely for the # common no-provider-args case. local test_suite_base="${test_file##*/}" local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/${test_suite_base%.sh}" [ -d "$test_suite_dir" ] || mkdir -p "$test_suite_dir" local sanitized_args="" if [ -n "${args[*]+"${args[*]}"}" ]; then sanitized_args=$(echo "${args[*]}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-|-$//') fi local template if [ -z "$sanitized_args" ]; then template="${fn_name}.XXXXXX" else template="${fn_name}-${sanitized_args}.XXXXXX" fi local unique_test_result_file if unique_test_result_file=$("$MKTEMP" -p "$test_suite_dir" "$template" 2>/dev/null); then true else unique_test_result_file=$("$MKTEMP" "$test_suite_dir/$template") fi mv "$unique_test_result_file" "${unique_test_result_file}.result" unique_test_result_file="${unique_test_result_file}.result" bashunit::internal_log "[PARA]" "fn_name:$fn_name" "execution_result:$execution_result" bashunit::runner::parse_result_sync "$fn_name" "$execution_result" echo "$execution_result" >"$unique_test_result_file" } # shellcheck disable=SC2295 ## # Parses the encoded per-test result's last line into the counts out-slots # (_BASHUNIT_RUNNER_COUNTS_*_OUT). Pure read: never mutates the cumulative # _BASHUNIT_ASSERTIONS_* / _BASHUNIT_TEST_EXIT_CODE state, so the retry loop can # judge an attempt's outcome without committing it. ## function bashunit::runner::extract_result_counts() { local execution_result=$1 local result_line result_line="${execution_result##*$'\n'}" local assertions_failed=0 local assertions_passed=0 local assertions_skipped=0 local assertions_incomplete=0 local assertions_snapshot=0 local test_exit_code=0 # Extract values using parameter expansion instead of spawning grep/sed subprocesses case "$result_line" in *"ASSERTIONS_FAILED="*"##ASSERTIONS_PASSED="*) local _tail _tail="${result_line##*ASSERTIONS_FAILED=}" assertions_failed="${_tail%%##*}" _tail="${result_line##*ASSERTIONS_PASSED=}" assertions_passed="${_tail%%##*}" _tail="${result_line##*ASSERTIONS_SKIPPED=}" assertions_skipped="${_tail%%##*}" _tail="${result_line##*ASSERTIONS_INCOMPLETE=}" assertions_incomplete="${_tail%%##*}" _tail="${result_line##*ASSERTIONS_SNAPSHOT=}" assertions_snapshot="${_tail%%##*}" _tail="${result_line##*TEST_EXIT_CODE=}" test_exit_code="${_tail%%##*}" # Strip any trailing non-digit suffix (end of line) from the final field test_exit_code="${test_exit_code%%[!0-9]*}" : "${assertions_failed:=0}" : "${assertions_passed:=0}" : "${assertions_skipped:=0}" : "${assertions_incomplete:=0}" : "${assertions_snapshot:=0}" : "${test_exit_code:=0}" ;; esac _BASHUNIT_RUNNER_COUNTS_FAILED_OUT=$assertions_failed _BASHUNIT_RUNNER_COUNTS_PASSED_OUT=$assertions_passed _BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT=$assertions_skipped _BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT=$assertions_incomplete _BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT=$assertions_snapshot _BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT=$test_exit_code } function bashunit::runner::parse_result_sync() { local fn_name=$1 local execution_result=$2 bashunit::runner::extract_result_counts "$execution_result" bashunit::internal_log "[SYNC]" "fn_name:$fn_name" "execution_result:$execution_result" _BASHUNIT_ASSERTIONS_PASSED=$((_BASHUNIT_ASSERTIONS_PASSED + _BASHUNIT_RUNNER_COUNTS_PASSED_OUT)) _BASHUNIT_ASSERTIONS_FAILED=$((_BASHUNIT_ASSERTIONS_FAILED + _BASHUNIT_RUNNER_COUNTS_FAILED_OUT)) _BASHUNIT_ASSERTIONS_SKIPPED=$((_BASHUNIT_ASSERTIONS_SKIPPED + _BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT)) _BASHUNIT_ASSERTIONS_INCOMPLETE=$((_BASHUNIT_ASSERTIONS_INCOMPLETE + _BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT)) _BASHUNIT_ASSERTIONS_SNAPSHOT=$((_BASHUNIT_ASSERTIONS_SNAPSHOT + _BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT)) _BASHUNIT_TEST_EXIT_CODE=$((_BASHUNIT_TEST_EXIT_CODE + _BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT)) bashunit::internal_log "result_summary" \ "failed:$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" \ "passed:$_BASHUNIT_RUNNER_COUNTS_PASSED_OUT" \ "skipped:$_BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT" \ "incomplete:$_BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT" \ "snapshot:$_BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT" \ "exit_code:$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" } function bashunit::runner::write_failure_result_output() { local test_file=$1 local fn_name=$2 local error_msg=$3 local raw_output="${4:-}" local line_number line_number=$(bashunit::helper::get_function_line_number "$fn_name") local test_nr="*" if ! bashunit::parallel::is_enabled; then test_nr=$(bashunit::state::get_tests_failed) fi local output_section="" if [ -n "$raw_output" ] && bashunit::env::is_show_output_on_failure_enabled; then output_section="\n Output:\n$raw_output" fi local source_context="" if [ -n "$line_number" ] && [ -f "$test_file" ]; then source_context=$(bashunit::runner::get_failure_source_context \ "$test_file" "$line_number") fi echo -e "$test_nr) $test_file:$line_number\n$error_msg$output_section$source_context" \ >>"$FAILURES_OUTPUT_PATH" } function bashunit::runner::get_failure_source_context() { local file=$1 local fn_line=$2 # Read the file once (a bash builtin loop) instead of forking `sed` to fetch # each line and `grep` to test each line for the closing brace. The fork count # no longer grows with the function length. local line_text line_num=0 assert_lines="" stripped trimmed while IFS= read -r line_text || [ -n "$line_text" ]; do line_num=$((line_num + 1)) # Skip everything up to and including the function definition line. if [ "$line_num" -le "$fn_line" ]; then continue fi # Stop at the closing brace of the function (a line that is only `}`). stripped="${line_text#"${line_text%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" if [ "$stripped" = "}" ]; then break fi # Collect lines containing assert calls case "$line_text" in *assert_* | *assert\ *) trimmed="${line_text#"${line_text%%[![:space:]]*}"}" assert_lines="${assert_lines}\n ${_BASHUNIT_COLOR_FAINT}${line_num}:${_BASHUNIT_COLOR_DEFAULT} ${trimmed}" ;; esac done <"$file" if [ -n "$assert_lines" ]; then echo -e "\n ${_BASHUNIT_COLOR_FAINT}Source:${_BASHUNIT_COLOR_DEFAULT}${assert_lines}" fi } function bashunit::runner::write_skipped_result_output() { local test_file=$1 local fn_name=$2 local output_msg=$3 local line_number line_number=$(bashunit::helper::get_function_line_number "$fn_name") local test_nr="*" if ! bashunit::parallel::is_enabled; then test_nr=$(bashunit::state::get_tests_skipped) fi echo -e "$test_nr) $test_file:$line_number\n$output_msg" >>"$SKIPPED_OUTPUT_PATH" } function bashunit::runner::write_incomplete_result_output() { local test_file=$1 local fn_name=$2 local output_msg=$3 local line_number line_number=$(bashunit::helper::get_function_line_number "$fn_name") local test_nr="*" if ! bashunit::parallel::is_enabled; then test_nr=$(bashunit::state::get_tests_incomplete) fi echo -e "$test_nr) $test_file:$line_number\n$output_msg" >>"$INCOMPLETE_OUTPUT_PATH" } function bashunit::runner::write_risky_result_output() { local test_file=$1 local fn_name=$2 local line_number line_number=$(bashunit::helper::get_function_line_number "$fn_name") local test_nr="*" if ! bashunit::parallel::is_enabled; then test_nr=$(bashunit::state::get_tests_risky) fi echo -e "$test_nr) $test_file:$line_number\nTest has no assertions (risky)" >>"$RISKY_OUTPUT_PATH" } function bashunit::runner::record_file_hook_failure() { local hook_name="$1" local test_file="$2" local hook_output="$3" local status="$4" local render_header="${5:-false}" if [ "$render_header" = true ]; then bashunit::runner::render_running_file_header "$test_file" true fi if [ -z "$hook_output" ]; then hook_output="Hook '$hook_name' failed with exit code $status" fi bashunit::state::add_tests_failed bashunit::console_results::print_error_test "$hook_name" "$hook_output" local _normalized_hook _normalized_hook="$(bashunit::helper::normalize_test_function_name "$hook_name")" bashunit::reports::add_test_failed "$test_file" "$_normalized_hook" 0 0 "$hook_output" bashunit::runner::write_failure_result_output "$test_file" "$hook_name" "$hook_output" return "$status" } function bashunit::runner::execute_file_hook() { local hook_name="$1" local test_file="$2" local render_header="${3:-false}" declare -F "$hook_name" >/dev/null 2>&1 || return 0 local hook_output="" local status=0 local hook_output_file hook_output_file=$(bashunit::temp_file "${hook_name}_output") # Enable errtrace to catch any failing command in the hook. # Using -E (errtrace) without -e (errexit) prevents the main process from # exiting on source failures (Bash 3.2 doesn't trigger ERR trap with -eE). # The ERR trap saves the exit status to a global variable, cleans up shell # options, and returns from the hook function to prevent subsequent commands # from executing. # Variables set before the failure are preserved since we don't use a subshell. _BASHUNIT_HOOK_ERR_STATUS=0 set -E if bashunit::env::is_strict_mode_enabled; then set -uo pipefail fi # The trap returns from the function where the failure occurred (early-exit # semantics for intermediate failing commands) — but only when that frame is # NOT this executor: on Bash >= 4 the trap also fires HERE when the hook call # itself returns non-zero, and an unconditional return skipped # record_file_hook_failure entirely (silent failures, off-by-one counts, #836). # shellcheck disable=SC2154 trap '_BASHUNIT_HOOK_ERR_STATUS=$? if [ "${FUNCNAME[0]:-}" != "bashunit::runner::execute_file_hook" ]; then set +Eu +o pipefail trap - ERR return $_BASHUNIT_HOOK_ERR_STATUS fi' ERR { "$hook_name" } >"$hook_output_file" 2>&1 # Real exit status of the hook, read from $? (this function runs without -e, # so a failing compound does not exit). The ERR-trap global alone is not # enough: a hook ending in a failing `cmd && var=x` guard returns non-zero # without ever firing the trap (&& lists are ERR-exempt), which silently # swallowed the failure (#836). status=$? if [ "$status" -eq 0 ]; then status=$_BASHUNIT_HOOK_ERR_STATUS fi trap - ERR set +Eu +o pipefail if [ -f "$hook_output_file" ]; then hook_output="" local line while IFS= read -r line; do [ -z "$hook_output" ] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" done <"$hook_output_file" rm -f "$hook_output_file" fi if [ $status -ne 0 ]; then bashunit::runner::record_file_hook_failure "$hook_name" "$test_file" "$hook_output" "$status" "$render_header" return $status fi if [ -n "$hook_output" ] && bashunit::env::is_verbose_enabled; then printf "%s\n" "$hook_output" fi return 0 } function bashunit::runner::run_set_up() { local _test_file="${1-}" bashunit::internal_log "run_set_up" bashunit::runner::execute_test_hook 'set_up' } function bashunit::runner::run_set_up_before_script() { local test_file="$1" bashunit::internal_log "run_set_up_before_script" # Check if hook exists first if ! declare -F "set_up_before_script" >/dev/null 2>&1; then return 0 fi local start_time start_time=$(bashunit::clock::now) # Enable coverage trap to attribute lines executed during set_up_before_script if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::enable_trap fi # Execute the hook (render_header=false since header is already rendered) bashunit::runner::execute_file_hook 'set_up_before_script' "$test_file" false local status=$? # Disable coverage trap after hook execution if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::disable_trap fi local end_time end_time=$(bashunit::clock::now) local duration_ns=$((end_time - start_time)) local duration_ms=$((duration_ns / 1000000)) # Print completion message only if hook succeeded if [ $status -eq 0 ]; then bashunit::console_results::print_hook_completed "set_up_before_script" "$duration_ms" fi return $status } function bashunit::runner::run_tear_down() { local _test_file="${1-}" bashunit::internal_log "run_tear_down" bashunit::runner::execute_test_hook 'tear_down' } function bashunit::runner::execute_test_hook() { local hook_name="$1" declare -F "$hook_name" >/dev/null 2>&1 || return 0 local hook_output="" local status=0 local hook_output_file hook_output_file=$(bashunit::temp_file "${hook_name}_output") # Enable errtrace to catch any failing command in the hook. # Using -E (errtrace) without -e (errexit) prevents the subshell from # exiting on source failures (Bash 3.2 doesn't trigger ERR trap with -eE). # The ERR trap saves the exit status to a global variable, cleans up shell # options, and returns from the hook function to prevent subsequent commands # from executing. # Variables set before the failure are preserved since we don't use a subshell. _BASHUNIT_HOOK_ERR_STATUS=0 set -E if bashunit::env::is_strict_mode_enabled; then set -uo pipefail fi # See the twin comment in execute_file_hook: conditional return keeps the # early-exit semantics for intermediate failures without silently returning # from THIS executor when the trap re-fires here on Bash >= 4 (#836). # shellcheck disable=SC2154 trap '_BASHUNIT_HOOK_ERR_STATUS=$? if [ "${FUNCNAME[0]:-}" != "bashunit::runner::execute_test_hook" ]; then set +Eu +o pipefail trap - ERR return $_BASHUNIT_HOOK_ERR_STATUS fi' ERR { "$hook_name" } >"$hook_output_file" 2>&1 # Real hook status from $?; the trap global alone misses failing # `cmd && var=x` guards (&& lists are ERR-exempt) (#836). status=$? if [ "$status" -eq 0 ]; then status=$_BASHUNIT_HOOK_ERR_STATUS fi trap - ERR set +Eu +o pipefail if [ -f "$hook_output_file" ]; then hook_output="" local line while IFS= read -r line; do [ -z "$hook_output" ] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" done <"$hook_output_file" rm -f "$hook_output_file" fi if [ $status -ne 0 ]; then local message="$hook_output" if [ -n "$hook_output" ]; then printf "%s" "$hook_output" else message="Hook '$hook_name' failed with exit code $status" printf "%s\n" "$message" >&2 fi bashunit::runner::record_test_hook_failure "$hook_name" "$message" "$status" return "$status" fi if [ -n "$hook_output" ]; then printf "%s" "$hook_output" fi return 0 } function bashunit::runner::record_test_hook_failure() { local hook_name="$1" local hook_message="$2" local status="$3" if [ -n "$_BASHUNIT_TEST_HOOK_FAILURE" ]; then return "$status" fi bashunit::state::set_test_hook_failure "$hook_name" bashunit::state::set_test_hook_message "$hook_message" return "$status" } function bashunit::runner::clear_mocks() { if [ "${#_BASHUNIT_MOCKED_FUNCTIONS[@]}" -eq 0 ]; then return fi local i for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do bashunit::unmock "${_BASHUNIT_MOCKED_FUNCTIONS[$i]:-}" done } function bashunit::runner::run_tear_down_after_script() { local test_file="$1" bashunit::internal_log "run_tear_down_after_script" # Check if hook exists first if ! declare -F "tear_down_after_script" >/dev/null 2>&1; then # Add blank line after tests if no tear_down hook if ! bashunit::env::is_simple_output_enabled && ! bashunit::env::is_failures_only_enabled && ! bashunit::env::is_no_progress_enabled && ! bashunit::parallel::is_enabled; then echo "" fi return 0 fi local start_time start_time=$(bashunit::clock::now) # Enable coverage trap to attribute lines executed during tear_down_after_script if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::enable_trap fi # Execute the hook bashunit::runner::execute_file_hook 'tear_down_after_script' "$test_file" local status=$? # Disable coverage trap after hook execution if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::disable_trap fi local end_time end_time=$(bashunit::clock::now) local duration_ns=$((end_time - start_time)) local duration_ms=$((duration_ns / 1000000)) # Print completion message only if hook succeeded if [ $status -eq 0 ]; then bashunit::console_results::print_hook_completed "tear_down_after_script" "$duration_ms" fi # Add blank line after tear_down output if ! bashunit::env::is_simple_output_enabled && ! bashunit::env::is_failures_only_enabled && ! bashunit::env::is_no_progress_enabled && ! bashunit::parallel::is_enabled; then echo "" fi return $status } ## # Unset a file's test functions once the file has been processed. # # Test files are sourced into the main shell, and their functions used to stay # defined for the whole run: every test's $() subshell then forked an # ever-growing shell, making multi-file runs quadratic in file count (#829). # In parallel mode the file's workers have already forked (with their own copy # of the functions) by the time this runs, so unsetting here is race-free. # Arguments: $1 - whitespace-separated test function names ## function bashunit::runner::clean_script_test_functions() { local IFS=$' \t\n' local fn for fn in $1; do unset -f "$fn" 2>/dev/null || true done } function bashunit::runner::clean_set_up_and_tear_down_after_script() { bashunit::internal_log "clean_set_up_and_tear_down_after_script" bashunit::helper::unset_if_exists 'set_up' bashunit::helper::unset_if_exists 'tear_down' bashunit::helper::unset_if_exists 'set_up_before_script' bashunit::helper::unset_if_exists 'tear_down_after_script' } # benchmark.sh _BASHUNIT_BENCH_NAMES=() _BASHUNIT_BENCH_REVS=() _BASHUNIT_BENCH_ITS=() _BASHUNIT_BENCH_AVERAGES=() _BASHUNIT_BENCH_MAX_MILLIS=() function bashunit::benchmark::parse_annotations() { local fn_name=$1 local script=$2 local revs=1 local its=1 local max_ms="" local annotation annotation=$(awk "/function[[:space:]]+${fn_name}[[:space:]]*\(/ {print prev; exit} {prev=\$0}" "$script") local _extracted _extracted=$(echo "$annotation" | sed -n 's/.*@revs=\([0-9][0-9]*\).*/\1/p') if [ -n "$_extracted" ]; then revs="$_extracted" else _extracted=$(echo "$annotation" | sed -n 's/.*@revolutions=\([0-9][0-9]*\).*/\1/p') if [ -n "$_extracted" ]; then revs="$_extracted" fi fi _extracted=$(echo "$annotation" | sed -n 's/.*@its=\([0-9][0-9]*\).*/\1/p') if [ -n "$_extracted" ]; then its="$_extracted" else _extracted=$(echo "$annotation" | sed -n 's/.*@iterations=\([0-9][0-9]*\).*/\1/p') if [ -n "$_extracted" ]; then its="$_extracted" fi fi _extracted=$(echo "$annotation" | sed -n 's/.*@max_ms=\([0-9.][0-9.]*\).*/\1/p') if [ -n "$_extracted" ]; then max_ms="$_extracted" fi if [ -n "$max_ms" ]; then echo "$revs" "$its" "$max_ms" else echo "$revs" "$its" fi } function bashunit::benchmark::add_result() { _BASHUNIT_BENCH_NAMES[${#_BASHUNIT_BENCH_NAMES[@]}]="$1" _BASHUNIT_BENCH_REVS[${#_BASHUNIT_BENCH_REVS[@]}]="$2" _BASHUNIT_BENCH_ITS[${#_BASHUNIT_BENCH_ITS[@]}]="$3" _BASHUNIT_BENCH_AVERAGES[${#_BASHUNIT_BENCH_AVERAGES[@]}]="$4" _BASHUNIT_BENCH_MAX_MILLIS[${#_BASHUNIT_BENCH_MAX_MILLIS[@]}]="$5" } # shellcheck disable=SC2155 function bashunit::benchmark::run_function() { local fn_name=$1 local revs=$2 local its=$3 local max_ms=$4 local IFS=$' \t\n' local -a durations=() local durations_count=0 local i r for ((i = 1; i <= its; i++)); do local start_time=$(bashunit::clock::now) ( for ((r = 1; r <= revs; r++)); do "$fn_name" >/dev/null 2>&1 done ) local end_time=$(bashunit::clock::now) local dur_ns=$(bashunit::math::calculate "($end_time - $start_time)") local dur_ms=$(bashunit::math::calculate "$dur_ns / 1000000") durations[durations_count]="$dur_ms" durations_count=$((durations_count + 1)) if bashunit::env::is_bench_mode_enabled; then local label="$(bashunit::helper::normalize_test_function_name "$fn_name")" local line="$label [$i/$its] ${dur_ms} ms" bashunit::state::print_line "successful" "$line" fi done local sum=0 local d for d in "${durations[@]+"${durations[@]}"}"; do sum=$(bashunit::math::calculate "$sum + $d") done local avg=$(bashunit::math::calculate "$sum / ${#durations[@]}") bashunit::benchmark::add_result "$fn_name" "$revs" "$its" "$avg" "$max_ms" } function bashunit::benchmark::print_results() { if ! bashunit::env::is_bench_mode_enabled; then return fi if ((${#_BASHUNIT_BENCH_NAMES[@]} == 0)); then return fi if bashunit::env::is_simple_output_enabled; then printf "\n" fi printf "\nBenchmark Results (avg ms)\n" bashunit::print_line 80 "=" printf "\n" local IFS=$' \t\n' local has_threshold=false local val for val in "${_BASHUNIT_BENCH_MAX_MILLIS[@]+"${_BASHUNIT_BENCH_MAX_MILLIS[@]}"}"; do if [ -n "$val" ]; then has_threshold=true break fi done if $has_threshold; then printf '%-40s %6s %6s %10s %12s\n' "Name" "Revs" "Its" "Avg(ms)" "Status" else printf '%-40s %6s %6s %10s\n' "Name" "Revs" "Its" "Avg(ms)" fi local i for i in "${!_BASHUNIT_BENCH_NAMES[@]}"; do local name="${_BASHUNIT_BENCH_NAMES[$i]:-}" local revs="${_BASHUNIT_BENCH_REVS[$i]:-}" local its="${_BASHUNIT_BENCH_ITS[$i]:-}" local avg="${_BASHUNIT_BENCH_AVERAGES[$i]:-}" local max_ms="${_BASHUNIT_BENCH_MAX_MILLIS[$i]:-}" if [ -z "$max_ms" ]; then printf '%-40s %6s %6s %10s\n' "$name" "$revs" "$its" "$avg" continue fi if [ "$avg" -le "$max_ms" ]; then local raw="≤ ${max_ms}" local padded padded=$(printf "%14s" "$raw") printf '%-40s %6s %6s %10s %12s\n' "$name" "$revs" "$its" "$avg" "$padded" continue fi local raw="> ${max_ms}" local padded padded=$(printf "%12s" "$raw") printf '%-40s %6s %6s %10s %s%s%s\n' \ "$name" "$revs" "$its" "$avg" \ "$_BASHUNIT_COLOR_FAILED" "$padded" "${_BASHUNIT_COLOR_DEFAULT}" done bashunit::console_results::print_execution_time } # bashunit.sh # This file provides a facade to developers who wants # to interact with the internals of bashunit. # e.g. adding custom assertions function bashunit::assertion_failed() { bashunit::assert::should_skip && return 0 local expected=$1 local actual=$2 local failure_condition_message=${3:-"but got "} local test_fn test_fn="$(bashunit::helper::find_test_function_name)" local label label="$(bashunit::helper::normalize_test_function_name "$test_fn")" bashunit::assert::mark_failed bashunit::console_results::print_failed_test "${label}" "${expected}" \ "$failure_condition_message" "${actual}" } function bashunit::assertion_passed() { bashunit::assert::should_skip && return 0 bashunit::state::add_assertions_passed } # init.sh function bashunit::init::project() { local tests_dir="${1:-$BASHUNIT_DEFAULT_PATH}" mkdir -p "$tests_dir" local bootstrap_file="$tests_dir/bootstrap.sh" if [ ! -f "$bootstrap_file" ]; then cat >"$bootstrap_file" <<'SH' #!/usr/bin/env bash set -euo pipefail # Place your common test setup here SH chmod +x "$bootstrap_file" echo "> Created $bootstrap_file" fi local example_test="$tests_dir/example_test.sh" if [ ! -f "$example_test" ]; then cat >"$example_test" <<'SH' #!/usr/bin/env bash function test_bashunit_is_installed() { assert_same "bashunit is installed" "bashunit is installed" } SH chmod +x "$example_test" echo "> Created $example_test" fi local workflow_dir=".github/workflows" local workflow_file="$workflow_dir/tests.yml" if [ ! -f "$workflow_file" ]; then mkdir -p "$workflow_dir" cat >"$workflow_file" < Created $workflow_file" fi local env_file=".env" local env_line="BASHUNIT_BOOTSTRAP=$bootstrap_file" if [ -f "$env_file" ]; then if grep -q "^BASHUNIT_BOOTSTRAP=" "$env_file"; then if bashunit::check_os::is_macos; then sed -i '' -e "s/^BASHUNIT_BOOTSTRAP=/#&/" "$env_file" else sed -i -e "s/^BASHUNIT_BOOTSTRAP=/#&/" "$env_file" fi fi echo "$env_line" >>"$env_file" else echo "$env_line" >"$env_file" fi echo "> bashunit initialized in $tests_dir" } # learn.sh # shellcheck disable=SC2016 ## # Interactive learning module for bashunit # Provides guided tutorials and exercises to learn bashunit ## LEARN_TEMP_DIR="" declare -r LEARN_PROGRESS_FILE="$HOME/.bashunit_learn_progress" ## # Initialize learning environment ## function bashunit::learn::init() { LEARN_TEMP_DIR=$("${MKTEMP:-mktemp}" -d "${TMPDIR:-/tmp}/bashunit_learn.XXXXXXXX") mkdir -p tests } ## # Cleanup learning environment ## function bashunit::learn::cleanup() { if [ -n "${LEARN_TEMP_DIR:-}" ] && [ -d "$LEARN_TEMP_DIR" ]; then rm -rf "$LEARN_TEMP_DIR" fi } ## # Print the learning menu ## function bashunit::learn::print_menu() { cat <>"$LEARN_PROGRESS_FILE" } ## # Check if lesson is completed ## function bashunit::learn::is_completed() { local lesson=$1 [ -f "$LEARN_PROGRESS_FILE" ] && [ "$("$GREP" -c "^$lesson$" "$LEARN_PROGRESS_FILE" || true)" -gt 0 ] } ## # Show learning progress ## function bashunit::learn::show_progress() { if [ ! -f "$LEARN_PROGRESS_FILE" ]; then echo "${_BASHUNIT_COLOR_INCOMPLETE}No progress yet. Start with lesson 1!${_BASHUNIT_COLOR_DEFAULT}" return fi echo "${_BASHUNIT_COLOR_BOLD}Your Progress:${_BASHUNIT_COLOR_DEFAULT}" echo "" local total_lessons=10 local completed=0 local i for i in $(seq 1 $total_lessons); do if bashunit::learn::is_completed "lesson_$i"; then echo " ${_BASHUNIT_COLOR_PASSED}✓${_BASHUNIT_COLOR_DEFAULT} Lesson $i completed" ((++completed)) || true else echo " ${_BASHUNIT_COLOR_INCOMPLETE}○${_BASHUNIT_COLOR_DEFAULT} Lesson $i" fi done echo "" echo "Progress: $completed/$total_lessons lessons completed" if [ $completed -eq $total_lessons ]; then echo "" printf "%s%s🎉 Congratulations! You've completed all lessons!%s\n" \ "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_BOLD" "$_BASHUNIT_COLOR_DEFAULT" fi read -p "Press Enter to continue..." -r } ## # Reset learning progress ## function bashunit::learn::reset_progress() { rm -f "$LEARN_PROGRESS_FILE" echo "${_BASHUNIT_COLOR_PASSED}Progress reset successfully.${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r } ## # Create the example file automatically # Arguments: $1 - filename, $2 - file content ## function bashunit::learn::create_example_file() { local filename=$1 local content=$2 echo "" echo "Creating example file ${_BASHUNIT_COLOR_BOLD}$filename${_BASHUNIT_COLOR_DEFAULT}..." echo "$content" >"$filename" chmod +x "$filename" echo "${_BASHUNIT_COLOR_PASSED}✓ Created $filename${_BASHUNIT_COLOR_DEFAULT}" echo "" echo "File created! Edit it to complete the TODO items, then run this lesson again." read -p "Press Enter to continue..." -r return 0 } ## # Run a lesson test and check results ## function bashunit::learn::run_lesson_test() { local test_file=$1 local lesson_number=$2 echo "${_BASHUNIT_COLOR_BOLD}Running your test...${_BASHUNIT_COLOR_DEFAULT}" echo "" if "$BASHUNIT_ROOT_DIR/bashunit" "$test_file" --simple; then echo "" printf "%s%s✓ Excellent! Lesson %s completed!%s\n" \ "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_BOLD" "$lesson_number" "$_BASHUNIT_COLOR_DEFAULT" bashunit::learn::mark_completed "lesson_$lesson_number" read -p "Press Enter to continue..." -r return 0 else echo "" echo "${_BASHUNIT_COLOR_FAILED}Not quite right. Review the requirements and try again.${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r return 1 fi } ## # Lesson 1: Basics - Your First Test ## function bashunit::learn::lesson_basics() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 1: Your First Test ║ ╚════════════════════════════════════════════════════════════════╝ Welcome to bashunit! Let's write your first test. CONCEPT: A test is a function that starts with 'test_' and uses assertions to verify behavior. TASK: Create a test file that checks if two values are equal. File: tests/first_test.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function test_bashunit_works() { # TODO: Use assert_same to check if "hello" equals "hello" # Hint: assert_same "expected" "actual" } ─────────────────────────────────────────────────────────────── TIPS: • The assert_same function takes two arguments: assert_same "expected" "actual" • Test functions must start with "test_" prefix • Always quote your strings to avoid word splitting • Keep test files in a tests/ directory for better organization EOF local default_file="tests/first_test.sh" echo "" printf "When ready, enter file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function test_bashunit_works() { # TODO: Use assert_same to check if "hello" equals "hello" # Hint: assert_same "expected" "actual" }' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi # Check if file contains assert_same if [ "$("$GREP" -c "assert_same" "$test_file" || true)" -eq 0 ]; then echo "${_BASHUNIT_COLOR_FAILED}Your test should use assert_same${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r return 1 fi bashunit::learn::run_lesson_test "$test_file" 1 } ## # Lesson 2: Assertions - Testing Different Conditions ## function bashunit::learn::lesson_assertions() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 2: Testing Different Conditions ║ ╚════════════════════════════════════════════════════════════════╝ CONCEPT: bashunit provides many assertion functions for different checks: • assert_same - exact equality • assert_contains - substring check • assert_matches - regex pattern • assert_not_same - inequality • assert_empty - checks if value is empty • assert_not_empty - checks if value is not empty TASK: Write a test file with 3 different assertions. File: tests/assertions_test.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function test_multiple_assertions() { local message="Hello, bashunit!" # TODO: Check that message contains "bashunit" # Hint: assert_contains "substring" "$message" # TODO: Check that message matches the pattern "Hello.*!" # Hint: assert_matches "pattern" "$message" # TODO: Check that message is not empty # Hint: assert_not_empty "$message" } ─────────────────────────────────────────────────────────────── TIPS: • assert_same checks exact equality (useful for strings/numbers) • assert_contains is more flexible for partial matches • assert_matches uses regex patterns (e.g., "^[0-9]+$" for numbers) • Explore more: assert_empty, assert_true, assert_false EOF local default_file="tests/assertions_test.sh" echo "" printf "When ready, enter file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function test_multiple_assertions() { local message="Hello, bashunit!" # TODO: Check that message contains "bashunit" # Hint: assert_contains "substring" "$message" # TODO: Check that message matches the pattern "Hello.*!" # Hint: assert_matches "pattern" "$message" # TODO: Check that message is not empty # Hint: assert_not_empty "$message" }' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi if [ "$("$GREP" -c "assert_contains" "$test_file" || true)" -eq 0 ] || [ "$("$GREP" -c "assert_matches" "$test_file" || true)" -eq 0 ] || [ "$("$GREP" -c "assert_not_empty" "$test_file" || true)" -eq 0 ]; then echo "${_BASHUNIT_COLOR_FAILED}Your test should use all three assertion types${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r return 1 fi bashunit::learn::run_lesson_test "$test_file" 2 } ## # Lesson 3: Setup & Teardown - Managing Test Lifecycle ## function bashunit::learn::lesson_lifecycle() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 3: Setup and Teardown Functions ║ ╚════════════════════════════════════════════════════════════════╝ CONCEPT: Tests often need preparation and cleanup. bashunit provides: • set_up() - runs before EACH test • tear_down() - runs after EACH test • set_up_before_script() - runs once before ALL tests • tear_down_after_script() - runs once after ALL tests TASK: Create a test that uses setup and teardown to manage files. File: tests/lifecycle_test.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function set_up() { # Create a temp file before each test # TODO: export TEST_FILE="/tmp/test_$$" # TODO: echo "test content" > "$TEST_FILE" } function tear_down() { # Clean up after each test # TODO: rm -f "$TEST_FILE" } function test_file_exists() { # TODO: assert_file_exists "$TEST_FILE" } function test_file_has_content() { # TODO: assert_file_contains "test content" "$TEST_FILE" } ─────────────────────────────────────────────────────────────── TIPS: • set_up() runs before EACH test (good for test isolation) • set_up_before_script() runs ONCE before all tests (good for expensive setup) • Always clean up in tear_down() to avoid polluting other tests • Use $$ for unique temp file names to avoid conflicts EOF local default_file="tests/lifecycle_test.sh" echo "" printf "When ready, enter file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function set_up() { # Create a temp file before each test # TODO: export TEST_FILE="/tmp/test_$$" # TODO: echo "test content" > "$TEST_FILE" } function tear_down() { # Clean up after each test # TODO: rm -f "$TEST_FILE" } function test_file_exists() { # TODO: assert_file_exists "$TEST_FILE" } function test_file_has_content() { # TODO: assert_file_contains "test content" "$TEST_FILE" }' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi if [ "$("$GREP" -c "function set_up()" "$test_file" || true)" -eq 0 ] || [ "$("$GREP" -c "function tear_down()" "$test_file" || true)" -eq 0 ]; then echo "${_BASHUNIT_COLOR_FAILED}Your test should define set_up and tear_down functions${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r return 1 fi bashunit::learn::run_lesson_test "$test_file" 3 } ## # Lesson 4: Testing Functions ## function bashunit::learn::lesson_functions() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 4: Testing Bash Functions ║ ╚════════════════════════════════════════════════════════════════╝ CONCEPT: To test functions, source the file containing them, then call them in your tests. TASK: Create a script with a function, then test it. File: calculator.sh (source code) ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function add() { echo $(($1 + $2)) } ─────────────────────────────────────────────────────────────── File: tests/calculator_test.sh (test file) ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function set_up() { # TODO: Source calculator.sh from parent directory # Hint: source ../calculator.sh } function test_add_positive_numbers() { # TODO: Test that add 2 3 returns "5" # Hint: result=$(add 2 3) # Hint: assert_same "5" "$result" } function test_add_negative_numbers() { # TODO: Test that add -2 -3 returns "-5" # Hint: result=$(add -2 -3) # Hint: assert_same "-5" "$result" } ─────────────────────────────────────────────────────────────── TIPS: • Source files in set_up() to reload them fresh for each test • Capture function output with: result=$(function_name args) • Test edge cases: positive, negative, zero, large numbers • Source files from parent directory: source ../file.sh EOF local default_file="tests/calculator_test.sh" echo "" printf "When ready, enter TEST file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function set_up() { # TODO: Source calculator.sh from parent directory # Hint: source ../calculator.sh } function test_add_positive_numbers() { # TODO: Test that add 2 3 returns "5" # Hint: result=$(add 2 3) # Hint: assert_same "5" "$result" } function test_add_negative_numbers() { # TODO: Test that add -2 -3 returns "-5" # Hint: result=$(add -2 -3) # Hint: assert_same "-5" "$result" }' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi if [ "$("$GREP" -c "source" "$test_file" || true)" -eq 0 ]; then echo "${_BASHUNIT_COLOR_FAILED}Your test should source the calculator.sh file${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r return 1 fi bashunit::learn::run_lesson_test "$test_file" 4 } ## # Lesson 5: Testing Scripts ## function bashunit::learn::lesson_scripts() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 5: Testing Bash Scripts ║ ╚════════════════════════════════════════════════════════════════╝ CONCEPT: Scripts that execute commands directly are tested differently. Run them and capture their output. TASK: Create a script and test its output. File: greeter.sh (source code) ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash name=${1:-World} echo "Hello, $name!" ─────────────────────────────────────────────────────────────── File: tests/greeter_test.sh (test file) ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function test_default_greeting() { # TODO: Run greeter.sh from parent directory and capture output # Hint: output=$(../greeter.sh) # TODO: Assert output contains "Hello, World!" # Hint: assert_contains "Hello, World!" "$output" } function test_custom_greeting() { # TODO: Run greeter.sh with argument "Alice" # Hint: output=$(../greeter.sh "Alice") # TODO: Assert output contains "Hello, Alice!" # Hint: assert_contains "Hello, Alice!" "$output" } ─────────────────────────────────────────────────────────────── TIPS: • Use command substitution: output=$(./script.sh) • Make scripts executable: chmod +x script.sh • Test both default behavior and with various arguments • Scripts run in subshells, so they can't modify parent environment • Run scripts from parent directory: ../script.sh EOF local default_file="tests/greeter_test.sh" echo "" printf "When ready, enter TEST file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function test_default_greeting() { # TODO: Run greeter.sh from parent directory and capture output # Hint: output=$(../greeter.sh) # TODO: Assert output contains "Hello, World!" # Hint: assert_contains "Hello, World!" "$output" } function test_custom_greeting() { # TODO: Run greeter.sh with argument "Alice" # Hint: output=$(../greeter.sh "Alice") # TODO: Assert output contains "Hello, Alice!" # Hint: assert_contains "Hello, Alice!" "$output" }' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi bashunit::learn::run_lesson_test "$test_file" 5 } ## # Lesson 6: Mocking ## function bashunit::learn::lesson_mocking() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 6: Mocking External Commands ║ ╚════════════════════════════════════════════════════════════════╝ CONCEPT: Mocks let you override external commands or functions to control their behavior in tests. TASK: Test a function that uses external commands. File: system_info.sh (source code) ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function get_system_info() { echo "OS: $(uname -s)" } ─────────────────────────────────────────────────────────────── File: tests/system_info_test.sh (test file) ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function set_up() { source ../system_info.sh } function test_system_info_on_linux() { # TODO: Mock uname to return "Linux" # Hint: mock uname echo "Linux" local output output=$(get_system_info) # TODO: Assert output contains "OS: Linux" } function test_system_info_on_macos() { # TODO: Mock uname to return "Darwin" local output output=$(get_system_info) # TODO: Assert output contains "OS: Darwin" } ─────────────────────────────────────────────────────────────── TIPS: • Mocks replace commands/functions with custom behavior • Syntax: mock command_name echo "mocked output" • Mocks are automatically cleaned up after each test • Use mocks to avoid calling expensive external commands EOF local default_file="tests/system_info_test.sh" echo "" printf "When ready, enter TEST file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function set_up() { source ../system_info.sh } function test_system_info_on_linux() { # TODO: Mock uname to return "Linux" # Hint: mock uname echo "Linux" local output output=$(get_system_info) # TODO: Assert output contains "OS: Linux" } function test_system_info_on_macos() { # TODO: Mock uname to return "Darwin" local output output=$(get_system_info) # TODO: Assert output contains "OS: Darwin" }' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi if [ "$("$GREP" -c "mock" "$test_file" || true)" -eq 0 ]; then echo "${_BASHUNIT_COLOR_FAILED}Your test should use mock${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r return 1 fi bashunit::learn::run_lesson_test "$test_file" 6 } ## # Lesson 7: Spies ## function bashunit::learn::lesson_spies() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 7: Spies - Verifying Calls ║ ╚════════════════════════════════════════════════════════════════╝ CONCEPT: Spies let you verify that functions were called with specific arguments or a certain number of times. KEY DIFFERENCE: Spies track calls without changing behavior, while mocks (Lesson 6) replace the function entirely with custom behavior. TASK: Use spies to verify function calls. File: deploy.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function deploy_app() { git push origin main docker build -t myapp . docker push myapp } ─────────────────────────────────────────────────────────────── File: deploy_test.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function set_up() { source deploy.sh } function test_deploy_calls_git_push() { # TODO: Create spies for git and docker # Hint: spy git # Hint: spy docker deploy_app # TODO: Assert git was called # Hint: assert_have_been_called git # TODO: Assert docker was called } function test_deploy_calls_docker_twice() { # TODO: Spy on docker deploy_app # TODO: Assert docker was called exactly 2 times # Hint: assert_have_been_called_times 2 docker } ─────────────────────────────────────────────────────────────── TIPS: • Spies track calls but don't change behavior (unlike mocks) • assert_have_been_called - verifies at least one call • assert_have_been_called_times N - verifies exact call count • assert_have_been_called_with - verifies specific arguments • Spies are cleaned up automatically after each test EOF local default_file="deploy_test.sh" echo "" printf "When ready, enter TEST file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function set_up() { source deploy.sh } function test_deploy_calls_git_push() { # TODO: Create spies for git and docker # Hint: spy git # Hint: spy docker deploy_app # TODO: Assert git was called # Hint: assert_have_been_called git # TODO: Assert docker was called } function test_deploy_calls_docker_twice() { # TODO: Spy on docker deploy_app # TODO: Assert docker was called exactly 2 times # Hint: assert_have_been_called_times 2 docker }' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi if [ "$("$GREP" -c "spy" "$test_file" || true)" -eq 0 ]; then echo "${_BASHUNIT_COLOR_FAILED}Your test should use spy${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r return 1 fi bashunit::learn::run_lesson_test "$test_file" 7 } ## # Lesson 8: Data Providers ## function bashunit::learn::lesson_data_providers() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 8: Data Providers - Parameterized Tests ║ ╚════════════════════════════════════════════════════════════════╝ CONCEPT: Data providers let you run the same test with different inputs. Define a function that echoes test data, one per line. HOW IT WORKS: Each line from data_provider_* becomes $1 in your test. The test runs once for each line of data. TASK: Test multiple email formats using a data provider. File: validator.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function is_valid_email() { local email_pattern='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' [ "$(echo "$1" | "$GREP" -cE "$email_pattern" || true)" -gt 0 ] } ─────────────────────────────────────────────────────────────── File: validator_test.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function set_up() { source validator.sh } function data_provider_valid_emails() { # TODO: Echo valid email addresses, one per line # Example: echo "user@example.com" } function test_valid_emails() { # $1 contains the email from data provider # TODO: Assert is_valid_email succeeds # Hint: assert_successful_code "is_valid_email \"$1\"" } function data_provider_invalid_emails() { # TODO: Echo invalid email addresses, one per line # Example: echo "not-an-email" } function test_invalid_emails() { # TODO: Assert is_valid_email fails # Hint: assert_general_error "is_valid_email \"$1\"" } ─────────────────────────────────────────────────────────────── TIPS: • Data providers must be named: data_provider_ • Each line of output becomes one test case • The test function receives the line as $1 • Great for testing multiple inputs without duplicating code • You can have multiple data provider/test pairs in one file EOF local default_file="validator_test.sh" echo "" printf "When ready, enter TEST file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function set_up() { source validator.sh } function data_provider_valid_emails() { # TODO: Echo valid email addresses, one per line # Example: echo "user@example.com" } function test_valid_emails() { # $1 contains the email from data provider # TODO: Assert is_valid_email succeeds # Hint: assert_successful_code "is_valid_email \"$1\"" } function data_provider_invalid_emails() { # TODO: Echo invalid email addresses, one per line # Example: echo "not-an-email" } function test_invalid_emails() { # TODO: Assert is_valid_email fails # Hint: assert_general_error "is_valid_email \"$1\"" }' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi if [ "$("$GREP" -c "function data_provider_" "$test_file" || true)" -eq 0 ]; then echo "${_BASHUNIT_COLOR_FAILED}Your test should define data provider functions${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r return 1 fi bashunit::learn::run_lesson_test "$test_file" 8 } ## # Lesson 9: Exit Codes ## function bashunit::learn::lesson_exit_codes() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 9: Testing Exit Codes ║ ╚════════════════════════════════════════════════════════════════╝ CONCEPT: Exit codes indicate success (0) or failure (non-zero). bashunit provides assertions to test them: • assert_successful_code - expects exit code 0 • assert_general_error - expects exit code 1 • assert_exit_code N - expects specific exit code N TASK: Test different exit codes. File: checker.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function check_file() { if [ ! -e "$1" ]; then echo "File not found" >&2 return 127 fi if [ ! -r "$1" ]; then echo "Permission denied" >&2 return 1 fi echo "File OK" return 0 } ─────────────────────────────────────────────────────────────── File: checker_test.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function set_up() { source checker.sh # Create a test file export TEST_FILE="/tmp/test_file_$$" touch "$TEST_FILE" } function tear_down() { rm -f "$TEST_FILE" } function test_existing_file_returns_success() { # TODO: Assert check_file succeeds with TEST_FILE # Hint: assert_successful_code "check_file '$TEST_FILE'" } function test_missing_file_returns_127() { # TODO: Assert check_file returns exit code 127 for missing file # Hint: assert_exit_code 127 "check_file '/nonexistent/file'" } ─────────────────────────────────────────────────────────────── TIPS: • Exit code 0 = success (assert_successful_code) • Exit code 1 = general error (assert_general_error) • Other codes = specific errors (assert_exit_code N) • Bash uses 'return N' in functions, 'exit N' in scripts • Common codes: 127=not found, 126=not executable, 2=misuse EOF local default_file="checker_test.sh" echo "" printf "When ready, enter TEST file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function set_up() { source checker.sh # Create a test file export TEST_FILE="/tmp/test_file_$$" touch "$TEST_FILE" } function tear_down() { rm -f "$TEST_FILE" } function test_existing_file_returns_success() { # TODO: Assert check_file succeeds with TEST_FILE # Hint: assert_successful_code "check_file '\''$TEST_FILE'\''" } function test_missing_file_returns_127() { # TODO: Assert check_file returns exit code 127 for missing file # Hint: assert_exit_code 127 "check_file '\''/nonexistent/file'\''" }' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi local _exit_assert_pattern="assert_successful_code\|assert_exit_code\|assert_general_error" if [ "$("$GREP" -c "$_exit_assert_pattern" "$test_file" || true)" -eq 0 ]; then echo "${_BASHUNIT_COLOR_FAILED}Your test should use exit code assertions${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r return 1 fi bashunit::learn::run_lesson_test "$test_file" 9 } ## # Lesson 10: Complete Challenge ## function bashunit::learn::lesson_challenge() { clear cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ Lesson 10: Complete Challenge - Backup Script ║ ╚════════════════════════════════════════════════════════════════╝ FINAL CHALLENGE: Combine everything you've learned! CONCEPT: Real-world tests combine multiple concepts: lifecycle management, assertions, exit codes, and test doubles. TASK: Create a backup script and comprehensive tests. File: backup.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash function create_backup() { local source=$1 local dest=$2 if [ ! -d "$source" ]; then echo "Source directory not found" >&2 return 1 fi tar -czf "$dest" -C "$source" . echo "Backup created: $dest" } ─────────────────────────────────────────────────────────────── File: backup_test.sh ─────────────────────────────────────────────────────────────── #!/usr/bin/env bash Your test must include: 1. set_up and tear_down functions 2. Test successful backup creation 3. Test failure when source doesn't exist 4. Mock or spy on tar command 5. Verify backup file exists 6. Check output message TIP: Combine patterns from all previous lessons! EOF local default_file="backup_test.sh" echo "" printf "When ready, enter TEST file path %s[%s]%s: " \ "${_BASHUNIT_COLOR_FAINT}" "$default_file" "${_BASHUNIT_COLOR_DEFAULT}" read -r test_file test_file="${test_file:-$default_file}" if [ ! -f "$test_file" ]; then local template='#!/usr/bin/env bash function set_up() { source backup.sh # TODO: Create test directories and variables } function tear_down() { # TODO: Clean up test files } function test_successful_backup() { # TODO: Test backup creation } function test_backup_failure_when_source_missing() { # TODO: Test failure case } # Add more tests as needed: # - Mock or spy on tar command # - Verify backup file exists # - Check output message # # TIPS: # - Combine lifecycle (set_up/tear_down) with file assertions # - Use spies to verify tar was called correctly # - Test both success and failure scenarios # - Mock external commands to avoid side effects' bashunit::learn::create_example_file "$test_file" "$template" return 1 fi # Verify the test has key components local -a missing_components=() local missing_components_count=0 if [ "$("$GREP" -c "function set_up()" "$test_file" || true)" -eq 0 ]; then missing_components[missing_components_count]="set_up function" missing_components_count=$((missing_components_count + 1)) fi if [ "$("$GREP" -c "function tear_down()" "$test_file" || true)" -eq 0 ]; then missing_components[missing_components_count]="tear_down function" missing_components_count=$((missing_components_count + 1)) fi if [ "$missing_components_count" -gt 0 ]; then echo "${_BASHUNIT_COLOR_FAILED}Missing required components:${_BASHUNIT_COLOR_DEFAULT}" printf " - %s\n" "${missing_components[@]}" read -p "Press Enter to continue..." -r return 1 fi if bashunit::learn::run_lesson_test "$test_file" 10; then echo "" echo "${_BASHUNIT_COLOR_PASSED}${_BASHUNIT_COLOR_BOLD}" cat <<'EOF' ╔════════════════════════════════════════════════════════════════╗ ║ 🎉 CONGRATULATIONS! 🎉 ║ ║ ║ ║ You've completed all bashunit lessons! ║ ║ ║ ║ You now know how to: ║ ║ ✓ Write and run tests ║ ║ ✓ Use various assertions ║ ║ ✓ Manage test lifecycle ║ ║ ✓ Test functions and scripts ║ ║ ✓ Mock external dependencies ║ ║ ✓ Spy on function calls ║ ║ ✓ Use data providers ║ ║ ✓ Test exit codes ║ ║ ║ ║ Next steps: ║ ║ • Explore https://bashunit.com ║ ║ • Check out /common-patterns for more examples ║ ║ • Start testing your own bash scripts! ║ ╚════════════════════════════════════════════════════════════════╝ EOF echo "${_BASHUNIT_COLOR_DEFAULT}" read -p "Press Enter to continue..." -r fi } # main.sh ## # Validates a `--shard /` spec and exports the parts, or prints an # error and exits non-zero. Requires numeric index/total with 1 <= index <= total. ## function bashunit::main::set_shard_or_exit() { local spec="${1:-}" local index total case "$spec" in */*) index="${spec%%/*}" total="${spec##*/}" ;; *) index="" total="" ;; esac case "$index" in '' | *[!0-9]*) index="" ;; esac case "$total" in '' | *[!0-9]*) total="" ;; esac if [ -z "$index" ] || [ -z "$total" ] || [ "$total" -lt 1 ] || [ "$index" -lt 1 ] || [ "$index" -gt "$total" ]; then printf "%sError: --shard must be / with 1 <= index <= total (e.g. 1/4).%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi BASHUNIT_SHARD_INDEX="$index" export -n BASHUNIT_SHARD_INDEX BASHUNIT_SHARD_TOTAL="$total" export -n BASHUNIT_SHARD_TOTAL } ############################# # Subcommand: test ############################# function bashunit::main::cmd_test() { local filter="" local tag_filter="" local exclude_tag_filter="" local IFS=$' \t\n' local -a raw_args=() local raw_args_count=0 local -a args=() local args_count=0 local assert_fn="" local _bashunit_coverage_opt_set=false # Parse test-specific options. # # Flag branches assign WITHOUT export and strip the export attribute with # `export -n`: run-mode flags are this-process-only. Everything that reads # them (runner, reporters, parallel workers) runs in this shell or its # subshells, which inherit unexported variables — while exec'd children # (nested bashunit runs: bashunit's own acceptance suite under # `build.sh --verify`, or a user's script under test that calls bashunit) # must NOT inherit the parent's flags (#834, #837). The explicit `export -n` # also clears an export attribute stamped by an allexport .env load. # Pair any newly exported-by-necessity flag with a comment naming the exec'd # consumer, and extend tests/acceptance/fixtures/flag_env_leak/leak_probe.sh. while [ $# -gt 0 ]; do case "$1" in -a | --assert) assert_fn="$2" shift ;; -f | --filter) filter="$2" shift ;; --tag) if [ -z "$tag_filter" ]; then tag_filter="$2" else tag_filter="$tag_filter,$2" fi shift ;; --exclude-tag) if [ -z "$exclude_tag_filter" ]; then exclude_tag_filter="$2" else exclude_tag_filter="$exclude_tag_filter,$2" fi shift ;; -s | --simple) BASHUNIT_SIMPLE_OUTPUT=true export -n BASHUNIT_SIMPLE_OUTPUT ;; --detailed) BASHUNIT_SIMPLE_OUTPUT=false export -n BASHUNIT_SIMPLE_OUTPUT ;; --output) BASHUNIT_OUTPUT_FORMAT="$2" export -n BASHUNIT_OUTPUT_FORMAT shift ;; --debug) local output_file="${2:-}" if [ -n "$output_file" ] && [ "${output_file:0:1}" != "-" ]; then exec >"$output_file" 2>&1 shift fi set -x ;; -S | --stop-on-failure) # This-process-only: parallel stop uses a flag file and sync stop uses # exit codes, so no child process needs it — exported (including via an # allexport .env load, hence export -n) it leaked into nested bashunit # runs, aborting them before rerun::persist could write # .bashunit/last-failed (broke verify's acceptance tests, #834). BASHUNIT_STOP_ON_FAILURE=true export -n BASHUNIT_STOP_ON_FAILURE ;; -p | --parallel) BASHUNIT_PARALLEL_RUN=true export -n BASHUNIT_PARALLEL_RUN ;; -j | --jobs) BASHUNIT_PARALLEL_RUN=true export -n BASHUNIT_PARALLEL_RUN # "auto" caps at the detected core count; wait_for_job_slot needs an # integer, so resolve it here rather than leaking the string downstream. if [ "$2" = "auto" ]; then BASHUNIT_PARALLEL_JOBS="$(bashunit::check_os::nproc)" export -n BASHUNIT_PARALLEL_JOBS else BASHUNIT_PARALLEL_JOBS="$2" export -n BASHUNIT_PARALLEL_JOBS fi shift ;; --no-parallel) BASHUNIT_PARALLEL_RUN=false export -n BASHUNIT_PARALLEL_RUN ;; --test-timeout) BASHUNIT_TEST_TIMEOUT="$2" export -n BASHUNIT_TEST_TIMEOUT shift ;; --retry) BASHUNIT_RETRY="$2" export -n BASHUNIT_RETRY shift ;; --random-order) BASHUNIT_RANDOM_ORDER=true export -n BASHUNIT_RANDOM_ORDER ;; --seed) BASHUNIT_SEED="$2" export -n BASHUNIT_SEED shift ;; --shard) bashunit::main::set_shard_or_exit "$2" shift ;; --rerun-failed) BASHUNIT_RERUN_FAILED=true export -n BASHUNIT_RERUN_FAILED ;; -w | --watch) BASHUNIT_WATCH_MODE=true export -n BASHUNIT_WATCH_MODE ;; -e | --env | --boot) # Support: --env "bootstrap.sh arg1 arg2" local boot_file="${2%% *}" local boot_args="${2#* }" if [ "$boot_args" != "$2" ]; then BASHUNIT_BOOTSTRAP_ARGS="$boot_args" export -n BASHUNIT_BOOTSTRAP_ARGS fi # Export all variables from the env file so they're available in subshells # (e.g., process substitution used in load_test_files) set -o allexport # shellcheck disable=SC1090,SC2086 source "$boot_file" ${BASHUNIT_BOOTSTRAP_ARGS:-} set +o allexport shift ;; # Report flags are this-process-only: reports are generated by the main # shell after aggregation, no child process reads these. `export -n` also # strips an export attribute inherited from an allexport .env load — # otherwise nested bashunit runs (bashunit's own acceptance tests, or a # user's scripts under test that call bashunit) silently write their own # reports over the parent's files and blow the per-run fork budget (#834). --log-junit | --report-junit) BASHUNIT_LOG_JUNIT="$2" export -n BASHUNIT_LOG_JUNIT shift ;; --log-gha) BASHUNIT_LOG_GHA="$2" export -n BASHUNIT_LOG_GHA shift ;; -r | --report-html) BASHUNIT_REPORT_HTML="$2" export -n BASHUNIT_REPORT_HTML shift ;; --report-tap) BASHUNIT_REPORT_TAP="$2" export -n BASHUNIT_REPORT_TAP shift ;; --report-json) BASHUNIT_REPORT_JSON="$2" export -n BASHUNIT_REPORT_JSON shift ;; --no-output) BASHUNIT_NO_OUTPUT=true export -n BASHUNIT_NO_OUTPUT ;; -vvv | --verbose) BASHUNIT_VERBOSE=true export -n BASHUNIT_VERBOSE ;; -h | --help) bashunit::console_header::print_test_help exit 0 ;; --show-skipped) BASHUNIT_SHOW_SKIPPED=true export -n BASHUNIT_SHOW_SKIPPED ;; --show-incomplete) BASHUNIT_SHOW_INCOMPLETE=true export -n BASHUNIT_SHOW_INCOMPLETE ;; --failures-only) BASHUNIT_FAILURES_ONLY=true export -n BASHUNIT_FAILURES_ONLY ;; --fail-on-risky) BASHUNIT_FAIL_ON_RISKY=true export -n BASHUNIT_FAIL_ON_RISKY ;; --profile) BASHUNIT_PROFILE=true export -n BASHUNIT_PROFILE ;; --show-output) BASHUNIT_SHOW_OUTPUT_ON_FAILURE=true export -n BASHUNIT_SHOW_OUTPUT_ON_FAILURE ;; --no-output-on-failure) BASHUNIT_SHOW_OUTPUT_ON_FAILURE=false export -n BASHUNIT_SHOW_OUTPUT_ON_FAILURE ;; --no-progress) BASHUNIT_NO_PROGRESS=true export -n BASHUNIT_NO_PROGRESS ;; --strict) BASHUNIT_STRICT_MODE=true export -n BASHUNIT_STRICT_MODE ;; -R | --run-all) BASHUNIT_STOP_ON_ASSERTION_FAILURE=false export -n BASHUNIT_STOP_ON_ASSERTION_FAILURE ;; --skip-env-file) BASHUNIT_SKIP_ENV_FILE=true export -n BASHUNIT_SKIP_ENV_FILE ;; -l | --login) BASHUNIT_LOGIN_SHELL=true export -n BASHUNIT_LOGIN_SHELL ;; --no-color) # shellcheck disable=SC2034 BASHUNIT_NO_COLOR=true ;; --coverage) # Don't export - prevents nested bashunit runs from inheriting coverage # shellcheck disable=SC2034 BASHUNIT_COVERAGE=true ;; --coverage-paths) # shellcheck disable=SC2034 BASHUNIT_COVERAGE_PATHS="$2" shift ;; --coverage-exclude) # shellcheck disable=SC2034 BASHUNIT_COVERAGE_EXCLUDE="$2" shift ;; --coverage-report) # shellcheck disable=SC2034 BASHUNIT_COVERAGE_REPORT="$2" _bashunit_coverage_opt_set=true shift ;; --coverage-min) # shellcheck disable=SC2034 BASHUNIT_COVERAGE_MIN="$2" _bashunit_coverage_opt_set=true shift ;; --no-coverage-report) # shellcheck disable=SC2034 BASHUNIT_COVERAGE_REPORT="" ;; --coverage-report-html) # shellcheck disable=SC2034 # Use default if no value provided or next arg is a flag if [ -z "${2:-}" ]; then BASHUNIT_COVERAGE_REPORT_HTML="coverage/html" else case "${2:-}" in -*) BASHUNIT_COVERAGE_REPORT_HTML="coverage/html" ;; *) BASHUNIT_COVERAGE_REPORT_HTML="$2" shift ;; esac fi _bashunit_coverage_opt_set=true ;; *) raw_args[raw_args_count]="$1" raw_args_count=$((raw_args_count + 1)) ;; esac shift done # Auto-enable coverage when any coverage output option is specified if [ "$_bashunit_coverage_opt_set" = true ]; then # shellcheck disable=SC2034 BASHUNIT_COVERAGE=true fi # Expand positional arguments and extract inline filters # Skip filter parsing for assert mode - args are not file paths local inline_filter="" local inline_filter_file="" if [ "$raw_args_count" -gt 0 ]; then if [ -n "$assert_fn" ]; then # Assert mode: pass args as-is without file path processing args=("${raw_args[@]}") args_count="$raw_args_count" else # Test mode: process file paths and extract inline filters local arg for arg in "${raw_args[@]+"${raw_args[@]}"}"; do local parsed_path parsed_filter { read -r parsed_path read -r parsed_filter } < <(bashunit::helper::parse_file_path_filter "$arg") # If an inline filter was found, store it if [ -n "$parsed_filter" ]; then inline_filter="$parsed_filter" inline_filter_file="$parsed_path" fi local file while IFS= read -r file; do args[args_count]="$file" args_count=$((args_count + 1)) done < <(bashunit::helper::find_files_recursive "$parsed_path" '*[tT]est.sh') done # Resolve line number filter to function name case "$inline_filter" in "__line__:"*) local line_number="${inline_filter#__line__:}" local resolved_file="${inline_filter_file}" # If the file path was a pattern, use the first resolved file if [ "$args_count" -gt 0 ]; then resolved_file="${args[0]}" fi inline_filter=$(bashunit::helper::find_function_at_line "$resolved_file" "$line_number") if [ -z "$inline_filter" ]; then printf "%sError: No test function found at line %s in %s%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$line_number" "$resolved_file" "${_BASHUNIT_COLOR_DEFAULT}" exit 1 fi ;; esac # Use inline filter if no -f filter was provided if [ -z "$filter" ] && [ -n "$inline_filter" ]; then filter="$inline_filter" fi fi fi # --rerun-failed: restrict discovery to the files recorded as failing last # run. Function-level filtering happens in the runner; --filter/--tag still # apply on top. With no recorded failures, fall back to the full suite. if [ -z "$assert_fn" ] && bashunit::rerun::is_enabled; then bashunit::rerun::load if bashunit::rerun::has_entries; then local -a _rerun_files=() local _rerun_file while IFS= read -r _rerun_file; do [ -z "$_rerun_file" ] && continue # Skip entries pointing at deleted files, don't crash. [ -f "$_rerun_file" ] || continue _rerun_files[${#_rerun_files[@]}]="$_rerun_file" done < <(bashunit::rerun::files) if [ "${#_rerun_files[@]}" -gt 0 ]; then args=("${_rerun_files[@]}") args_count=${#args[@]} fi else printf "%sNo previously failing tests recorded; running the full suite.%s\n" \ "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" fi fi # Optional bootstrap # shellcheck disable=SC1090,SC2086 [ -f "${BASHUNIT_BOOTSTRAP:-}" ] && source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} if [ "${BASHUNIT_NO_OUTPUT:-false}" = true ]; then exec >/dev/null 2>&1 fi # Disable strict mode for test execution to allow: # - Empty array expansion (set +u) # - Non-zero exit codes from failing tests (set +e) # - Pipe failures in test output (set +o pipefail) set +euo pipefail if [ -n "$assert_fn" ]; then # Disable coverage for assert mode - it's meant for running single assertions, # not tracking code coverage. This also prevents issues when parent bashunit # runs with coverage and calls subprocess bashunit with -a flag. BASHUNIT_COVERAGE=false export -n BASHUNIT_COVERAGE bashunit::main::exec_assert "$assert_fn" ${args+"${args[@]}"} else if [ "${BASHUNIT_WATCH_MODE:-false}" = true ]; then bashunit::main::watch_loop \ "$filter" "$tag_filter" "$exclude_tag_filter" \ ${args+"${args[@]}"} else if [ "$args_count" -gt 0 ]; then bashunit::main::exec_tests \ "$filter" "$tag_filter" "$exclude_tag_filter" \ "${args[@]}" else bashunit::main::exec_tests \ "$filter" "$tag_filter" "$exclude_tag_filter" fi fi fi } ############################# # Subcommand: bench ############################# function bashunit::main::cmd_bench() { local filter="" local IFS=$' \t\n' local -a raw_args=() local raw_args_count=0 local -a args=() local args_count=0 BASHUNIT_BENCH_MODE=true export -n BASHUNIT_BENCH_MODE # Parse bench-specific options while [ $# -gt 0 ]; do case "$1" in -f | --filter) filter="$2" shift ;; -s | --simple) BASHUNIT_SIMPLE_OUTPUT=true export -n BASHUNIT_SIMPLE_OUTPUT ;; --detailed) BASHUNIT_SIMPLE_OUTPUT=false export -n BASHUNIT_SIMPLE_OUTPUT ;; -e | --env | --boot) # Support: --env "bootstrap.sh arg1 arg2" local boot_file="${2%% *}" local boot_args="${2#* }" if [ "$boot_args" != "$2" ]; then BASHUNIT_BOOTSTRAP_ARGS="$boot_args" export -n BASHUNIT_BOOTSTRAP_ARGS fi # Export all variables from the env file so they're available in subshells # (e.g., process substitution used in load_test_files) set -o allexport # shellcheck disable=SC1090,SC2086 source "$boot_file" ${BASHUNIT_BOOTSTRAP_ARGS:-} set +o allexport shift ;; -vvv | --verbose) BASHUNIT_VERBOSE=true export -n BASHUNIT_VERBOSE ;; --skip-env-file) BASHUNIT_SKIP_ENV_FILE=true export -n BASHUNIT_SKIP_ENV_FILE ;; -l | --login) BASHUNIT_LOGIN_SHELL=true export -n BASHUNIT_LOGIN_SHELL ;; --no-color) # shellcheck disable=SC2034 BASHUNIT_NO_COLOR=true ;; -h | --help) bashunit::console_header::print_bench_help exit 0 ;; *) raw_args[raw_args_count]="$1" raw_args_count=$((raw_args_count + 1)) ;; esac shift done # Expand positional arguments if [ "$raw_args_count" -gt 0 ]; then local arg file for arg in "${raw_args[@]+"${raw_args[@]}"}"; do while IFS= read -r file; do args[args_count]="$file" args_count=$((args_count + 1)) done < <(bashunit::helper::find_files_recursive "$arg" '*[bB]ench.sh') done fi # Optional bootstrap # shellcheck disable=SC1090,SC2086 [ -f "${BASHUNIT_BOOTSTRAP:-}" ] && source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} set +euo pipefail # Bash 3.0 compatible: only pass args if we have files if [ "$args_count" -gt 0 ]; then bashunit::main::exec_benchmarks "$filter" "${args[@]}" else bashunit::main::exec_benchmarks "$filter" fi } ############################# # Subcommand: doc ############################# function bashunit::main::cmd_doc() { case "${1:-}" in -h | --help) bashunit::console_header::print_doc_help exit 0 ;; esac bashunit::doc::print_asserts "${1:-}" exit 0 } ############################# # Subcommand: init ############################# function bashunit::main::cmd_init() { case "${1:-}" in -h | --help) bashunit::console_header::print_init_help exit 0 ;; esac bashunit::init::project "${1:-}" exit 0 } ############################# # Subcommand: learn ############################# function bashunit::main::cmd_learn() { case "${1:-}" in -h | --help) bashunit::console_header::print_learn_help exit 0 ;; esac bashunit::learn::start exit 0 } ############################# # Subcommand: watch ############################# function bashunit::main::cmd_watch() { local path="" local -a extra_args=() while [ $# -gt 0 ]; do case "$1" in -h | --help) bashunit::console_header::print_watch_help exit 0 ;; -f | --filter) # Forward the filter flag and its value to the underlying test run extra_args[${#extra_args[@]}]="$1" shift || true if [ $# -gt 0 ]; then extra_args[${#extra_args[@]}]="$1" fi ;; -*) extra_args[${#extra_args[@]}]="$1" ;; *) if [ -z "$path" ]; then path="$1" else extra_args[${#extra_args[@]}]="$1" fi ;; esac shift || true done [ -z "$path" ] && path="." bashunit::watch::run "$path" "${extra_args[@]+"${extra_args[@]}"}" } ############################# # Subcommand: upgrade ############################# function bashunit::main::cmd_upgrade() { case "${1:-}" in -h | --help) bashunit::console_header::print_upgrade_help exit 0 ;; esac bashunit::upgrade::upgrade exit 0 } ############################# # Subcommand: assert ############################# # Check if a name corresponds to an assertion function (not a file or command) function bashunit::main::is_assertion_function() { local name="$1" declare -F "assert_$name" &>/dev/null || declare -F "$name" &>/dev/null } # Check if assertion operates on exit codes function bashunit::main::is_exit_code_assertion() { local name="$1" case "$name" in exit_code | successful_code | unsuccessful_code | general_error | command_not_found) return 0 ;; *) return 1 ;; esac } function bashunit::main::cmd_assert() { case "${1:-}" in -h | --help) bashunit::console_header::print_assert_help exit 0 ;; esac local first_arg="${1:-}" if [ -z "$first_arg" ]; then printf "%sError: Assert function name or command is required.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" bashunit::console_header::print_assert_help exit 1 fi # Disable strict mode for assert execution set +euo pipefail # Route to appropriate handler based on first argument if bashunit::main::is_assertion_function "$first_arg"; then # Old single-assertion syntax: bashunit assert local assert_fn="$first_arg" shift bashunit::main::exec_assert "$assert_fn" "$@" elif [ $# -ge 2 ] && bashunit::main::is_assertion_function "$2"; then # New multi-assertion syntax: bashunit assert "" ... # Detected by: first arg is not assertion, but second arg is an assertion name bashunit::main::exec_multi_assert "$@" else # Fallback: try as single assertion (may fail with function not found) bashunit::main::exec_assert "$@" fi exit $? } ############################# # Watch mode ############################# function bashunit::main::watch_get_checksum() { local IFS=$' \t\n' local -a paths=("$@") local file checksum="" for file in "${paths[@]+"${paths[@]}"}"; do if [ -d "$file" ]; then local found found=$(find "$file" -name '*.sh' -type f \ -exec stat -c '%Y %n' {} + 2>/dev/null || find "$file" -name '*.sh' -type f \ -exec stat -f '%m %N' {} + 2>/dev/null) || true checksum="${checksum}${found}" elif [ -f "$file" ]; then local mtime mtime=$(stat -c '%Y' "$file" 2>/dev/null || stat -f '%m' "$file" 2>/dev/null) || true checksum="${checksum}${mtime} ${file}" fi done echo "$checksum" } function bashunit::main::watch_loop() { local filter="$1" local tag_filter="${2:-}" local exclude_tag_filter="${3:-}" shift 3 local IFS=$' \t\n' local -a watch_paths=("$@") [ -d "src" ] && watch_paths[${#watch_paths[@]}]="src" trap 'printf "\n%sWatch mode stopped.%s\n" \ "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}"; \ exit 0' INT local last_checksum="" while true; do local current_checksum current_checksum=$(bashunit::main::watch_get_checksum \ "${watch_paths[@]}") if [ "$current_checksum" != "$last_checksum" ]; then last_checksum="$current_checksum" bashunit::io::clear_screen printf "%s[watch] Running tests...%s\n\n" \ "${_BASHUNIT_COLOR_SKIPPED}" \ "${_BASHUNIT_COLOR_DEFAULT}" ( if [ $# -gt 0 ]; then bashunit::main::exec_tests \ "$filter" "$tag_filter" \ "$exclude_tag_filter" "$@" else bashunit::main::exec_tests \ "$filter" "$tag_filter" \ "$exclude_tag_filter" fi ) || true printf "\n%s[watch] Waiting for changes...%s\n" \ "${_BASHUNIT_COLOR_SKIPPED}" \ "${_BASHUNIT_COLOR_DEFAULT}" fi sleep 1 done } ############################# # Test execution ############################# function bashunit::main::exec_tests() { local filter=$1 local tag_filter="${2:-}" local exclude_tag_filter="${3:-}" shift 3 # Bash 3.0 compatible: collect files into array local test_files local test_files_count=0 local _line while IFS= read -r _line; do [ -z "$_line" ] && continue test_files[test_files_count]="$_line" test_files_count=$((test_files_count + 1)) done < <(bashunit::helper::load_test_files "$filter" "$@") bashunit::internal_log "exec_tests" "filter:$filter" "files:${test_files[*]:-}" if [ "$test_files_count" -eq 0 ]; then printf "%sError: At least one file path is required.%s\n" "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" bashunit::console_header::print_help exit 1 fi # Split the suite across runners: keep the files whose position matches this # shard (round-robin), so all shards together cover the whole suite with no # overlap. An empty shard (more shards than files) is valid and runs nothing. if bashunit::env::is_shard_enabled; then local _shard_index _shard_total _shard_index=$(bashunit::env::shard_index) _shard_total=$(bashunit::env::shard_total) local -a _sharded=() local _i=0 while [ "$_i" -lt "$test_files_count" ]; do if [ "$((_i % _shard_total))" -eq "$((_shard_index - 1))" ]; then _sharded[${#_sharded[@]}]="${test_files[_i]}" fi _i=$((_i + 1)) done test_files=("${_sharded[@]+"${_sharded[@]}"}") test_files_count=${#test_files[@]} bashunit::internal_log "shard" "index:$_shard_index" "total:$_shard_total" "files:$test_files_count" fi # Trap SIGINT (Ctrl-C) and call the cleanup function trap 'bashunit::main::cleanup' SIGINT trap '[ $? -eq $EXIT_CODE_STOP_ON_FAILURE ] && bashunit::main::handle_stop_on_failure_sync' EXIT # Resolve parallel mode once now that --parallel/--no-parallel are parsed, so # the per-test is_enabled reads a global instead of re-checking env + OS. bashunit::parallel::resolve_enabled if bashunit::env::is_parallel_run_enabled && ! bashunit::parallel::is_enabled; then printf "%sWarning: Parallel tests are supported on macOS, Ubuntu and Windows.\n" "${_BASHUNIT_COLOR_INCOMPLETE}" printf "For other OS (like Alpine), --parallel is not enabled due to inconsistent results,\n" printf "particularly involving race conditions.%s " "${_BASHUNIT_COLOR_DEFAULT}" printf "%sFallback using --no-parallel%s\n" "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" fi if bashunit::parallel::is_enabled; then bashunit::parallel::init fi if bashunit::env::is_tap_output_enabled; then printf "TAP version 13\n" else bashunit::console_header::print_version_with_env "$filter" "${test_files[@]}" fi # Resolve the shuffle seed once (generating one if absent) so it can be printed # for replay and inherited by parallel test-file subshells. if bashunit::env::is_random_order_enabled; then if [ -z "${BASHUNIT_SEED:-}" ]; then BASHUNIT_SEED=$RANDOM export -n BASHUNIT_SEED fi if ! bashunit::env::is_tap_output_enabled; then bashunit::console_header::print_random_order_seed "$BASHUNIT_SEED" fi fi if bashunit::env::is_verbose_enabled; then if bashunit::env::is_simple_output_enabled; then echo "" fi printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '#' printf "%s\n" "Filter: ${filter:-None}" printf "%s\n" "Total files: ${#test_files[@]}" printf "%s\n" "Test files:" printf -- "- %s\n" "${test_files[@]}" printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '.' bashunit::env::print_verbose printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '#' fi bashunit::runner::load_test_files "$filter" "$tag_filter" "$exclude_tag_filter" "${test_files[@]}" if bashunit::parallel::is_enabled; then wait fi if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then printf "\r%sStop on failure enabled...%s\n" "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" fi if ! bashunit::env::is_tap_output_enabled; then bashunit::console_results::print_failing_tests_and_reset bashunit::console_results::print_risky_tests_and_reset bashunit::console_results::print_incomplete_tests_and_reset bashunit::console_results::print_skipped_tests_and_reset fi bashunit::console_results::render_result exit_code=$? if bashunit::env::is_profile_enabled; then bashunit::console_results::print_profile_and_reset fi if [ -n "$BASHUNIT_LOG_JUNIT" ]; then bashunit::reports::generate_junit_xml "$BASHUNIT_LOG_JUNIT" fi if [ -n "$BASHUNIT_LOG_GHA" ]; then bashunit::reports::generate_gha_log "$BASHUNIT_LOG_GHA" fi if [ -n "$BASHUNIT_REPORT_HTML" ]; then bashunit::reports::generate_report_html "$BASHUNIT_REPORT_HTML" fi if [ -n "$BASHUNIT_REPORT_TAP" ]; then bashunit::reports::generate_report_tap "$BASHUNIT_REPORT_TAP" fi if [ -n "$BASHUNIT_REPORT_JSON" ]; then bashunit::reports::generate_report_json "$BASHUNIT_REPORT_JSON" fi # Generate coverage report if enabled if bashunit::env::is_coverage_enabled; then # Aggregate per-process coverage data from parallel runs if bashunit::parallel::is_enabled; then bashunit::coverage::aggregate_parallel fi bashunit::coverage::precompute_file_stats bashunit::coverage::report_text if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then bashunit::coverage::report_lcov "$BASHUNIT_COVERAGE_REPORT" fi if [ -n "$BASHUNIT_COVERAGE_REPORT_HTML" ]; then bashunit::coverage::report_html "$BASHUNIT_COVERAGE_REPORT_HTML" fi # Check minimum threshold if ! bashunit::coverage::check_threshold; then exit_code=1 fi bashunit::coverage::cleanup fi if bashunit::parallel::is_enabled; then bashunit::parallel::cleanup fi # Persist this run's failing tests so a later --rerun-failed can replay them. bashunit::rerun::persist # The rerun cache is read from the run dir above, so clean up only after it. bashunit::env::cleanup_run_output_dir bashunit::internal_log "Finished tests" "exit_code:$exit_code" exit $exit_code } function bashunit::main::exec_benchmarks() { local filter=$1 shift # Bash 3.0 compatible: collect files into array local bench_files local bench_files_count=0 local _line while IFS= read -r _line; do [ -z "$_line" ] && continue bench_files[bench_files_count]="$_line" bench_files_count=$((bench_files_count + 1)) done < <(bashunit::helper::load_bench_files "$filter" "$@") bashunit::internal_log "exec_benchmarks" "filter:$filter" "files:${bench_files[*]:-}" if [ "$bench_files_count" -eq 0 ]; then printf "%sError: At least one file path is required.%s\n" "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" bashunit::console_header::print_help exit 1 fi bashunit::console_header::print_version_with_env "$filter" "${bench_files[@]}" bashunit::runner::load_bench_files "$filter" "${bench_files[@]}" bashunit::benchmark::print_results bashunit::internal_log "Finished benchmarks" } function bashunit::main::cleanup() { printf "%sCaught Ctrl-C, killing all child processes...%s\n" \ "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" # Kill all child processes of this script pkill -P $$ bashunit::cleanup_script_temp_files if bashunit::parallel::is_enabled; then bashunit::parallel::cleanup fi bashunit::env::cleanup_run_output_dir exit 1 } function bashunit::main::handle_stop_on_failure_sync() { printf "\n%sStop on failure enabled...%s\n" "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" bashunit::console_results::print_failing_tests_and_reset bashunit::console_results::print_risky_tests_and_reset bashunit::console_results::print_incomplete_tests_and_reset bashunit::console_results::print_skipped_tests_and_reset bashunit::console_results::render_result if bashunit::env::is_profile_enabled; then bashunit::console_results::print_profile_and_reset fi bashunit::cleanup_script_temp_files if bashunit::parallel::is_enabled; then bashunit::parallel::cleanup fi bashunit::env::cleanup_run_output_dir exit 1 } function bashunit::main::exec_assert() { local original_assert_fn=$1 local -a args=() local args_count=$(($# - 1)) [ $# -gt 1 ] && args=("${@:2}") local assert_fn=$original_assert_fn # Check if the function exists if ! type "$assert_fn" >/dev/null 2>&1; then assert_fn="assert_$assert_fn" if ! type "$assert_fn" >/dev/null 2>&1; then echo "Function $original_assert_fn does not exist." 1>&2 exit 127 fi fi # Get the last argument safely by calculating the array length local last_index=$((args_count - 1)) local last_arg="${args[$last_index]}" local output="" local inner_exit_code=0 local bashunit_exit_code=0 # Handle different assert_* functions case "$assert_fn" in assert_exit_code) output=$(bashunit::main::handle_assert_exit_code "$last_arg") inner_exit_code=$? # Remove the last argument and append the exit code args=("${args[@]:0:last_index}") args[last_index]="$inner_exit_code" ;; *) # Add more cases here for other assert_* handlers if needed ;; esac if [ -n "$output" ]; then echo "$output" 1>&1 assert_fn="assert_same" fi # Set a friendly test title for CLI assert command output bashunit::state::set_test_title "assert ${original_assert_fn#assert_}" # Run the assertion function and write into stderr "$assert_fn" "${args[@]}" 1>&2 bashunit_exit_code=$? if [ "$(bashunit::state::get_tests_failed)" -gt 0 ] || [ "$(bashunit::state::get_assertions_failed)" -gt 0 ]; then return 1 fi return "$bashunit_exit_code" } function bashunit::main::handle_assert_exit_code() { local cmd="$1" local output local inner_exit_code=0 if command -v "${cmd%% *}" >/dev/null 2>&1; then output=$(eval "$cmd" 2>&1 || echo "inner_exit_code:$?") local last_line last_line=$(echo "$output" | tail -n 1) if [ "$(echo "$last_line" | "$GREP" -c 'inner_exit_code:[0-9]*' || true)" -gt 0 ]; then inner_exit_code=$(echo "$last_line" | grep -o 'inner_exit_code:[0-9]*' | cut -d':' -f2) local _re='^[0-9]+$' if [ "$(echo "$inner_exit_code" | "$GREP" -cE "$_re" || true)" -eq 0 ]; then inner_exit_code=1 fi output=$(echo "$output" | sed '$d') fi echo "$output" return "$inner_exit_code" else echo "Command not found: $cmd" 1>&2 return 127 fi } # Execute multiple assertions on a single command output # Usage: exec_multi_assert "command" assertion1 arg1 [assertion2 arg2 ...] function bashunit::main::exec_multi_assert() { local cmd="$1" shift # Require at least one assertion if [ $# -lt 1 ]; then printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2 printf "Usage: bashunit assert \"\" [ ...]\n" 1>&2 return 1 fi # Check that assertions come in pairs (assertion + arg) if [ $# -lt 2 ] || [ $(($# % 2)) -ne 0 ]; then local assertion_name="${1:-}" printf "%sError: Missing argument for assertion '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2 return 1 fi # Execute command and capture output + exit code local stdout local cmd_exit_code stdout=$(eval "$cmd" 2>&1) cmd_exit_code=$? # Print stdout for user visibility if [ -n "$stdout" ]; then echo "$stdout" 1>&1 fi # Parse and execute assertions in pairs local overall_result=0 while [ $# -gt 0 ]; do local assertion_name="$1" local assertion_arg="${2:-}" if [ -z "$assertion_arg" ]; then printf "%sError: Missing argument for assertion '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2 return 1 fi shift 2 # Resolve assertion function name local assert_fn="$assertion_name" if ! type "$assert_fn" &>/dev/null; then assert_fn="assert_$assertion_name" if ! type "$assert_fn" &>/dev/null; then printf "%sError: Unknown assertion '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2 return 1 fi fi # Set test title for this assertion bashunit::state::set_test_title "assert ${assertion_name#assert_}" # Execute assertion with appropriate argument if bashunit::main::is_exit_code_assertion "$assertion_name"; then # Exit code assertion: pass expected value and captured exit code "$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2 else # Output assertion: pass expected value and captured stdout "$assert_fn" "$assertion_arg" "$stdout" 1>&2 fi if [ "$(bashunit::state::get_assertions_failed)" -gt 0 ]; then overall_result=1 fi done return $overall_result } #!/usr/bin/env bash set -euo pipefail declare -r BASHUNIT_MIN_BASH_VERSION="3.0" function _check_bash_version() { local current_version if [[ -n ${BASHUNIT_TEST_BASH_VERSION:-} ]]; then # Checks if BASHUNIT_TEST_BASH_VERSION is set (typically for testing purposes) current_version="${BASHUNIT_TEST_BASH_VERSION}" elif [[ -n ${BASH_VERSINFO+set} ]]; then # Checks if the special Bash array BASH_VERSINFO exists. This array is only defined in Bash. current_version="${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" else # If not in Bash (e.g., running from Zsh). The pipeline extracts just the major.minor version (e.g., 3.0). current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)" fi local major IFS=. read -r major _ <<<"$current_version" if ((major < 3)); then printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 exit 1 fi } _check_bash_version # shellcheck disable=SC2034 declare -r BASHUNIT_VERSION="0.42.0" # shellcheck disable=SC2155 declare -r BASHUNIT_ROOT_DIR="$(dirname "${BASH_SOURCE[0]}")" export BASHUNIT_ROOT_DIR # Capture working directory at startup (before any test changes it) declare -r BASHUNIT_WORKING_DIR="$PWD" export BASHUNIT_WORKING_DIR # Early scan for flags that must be set before loading env.sh for arg in "$@"; do case "$arg" in --skip-env-file) # this-process-only (see the flag-parse loop in src/main.sh) (#837) BASHUNIT_SKIP_ENV_FILE=true export -n BASHUNIT_SKIP_ENV_FILE ;; -l | --login) BASHUNIT_LOGIN_SHELL=true export -n BASHUNIT_LOGIN_SHELL ;; --no-color) # shellcheck disable=SC2034 BASHUNIT_NO_COLOR=true ;; esac done bashunit::check_os::init bashunit::clock::init # Subcommand detection _SUBCOMMAND="" case "${1:-}" in test | bench | doc | init | learn | upgrade | assert | watch) _SUBCOMMAND="$1" shift ;; -v | --version) bashunit::console_header::print_version exit 0 ;; -h | --help) bashunit::console_header::print_help exit 0 ;; -*) # Flag without subcommand → assume "test" _SUBCOMMAND="test" ;; "") # No arguments → assume "test" (uses BASHUNIT_DEFAULT_PATH) _SUBCOMMAND="test" ;; *) # Path argument → assume "test" _SUBCOMMAND="test" ;; esac # Route to subcommand handler case "$_SUBCOMMAND" in test) bashunit::main::cmd_test "$@" ;; bench) bashunit::main::cmd_bench "$@" ;; doc) bashunit::main::cmd_doc "$@" ;; init) bashunit::main::cmd_init "$@" ;; learn) bashunit::main::cmd_learn "$@" ;; upgrade) bashunit::main::cmd_upgrade "$@" ;; assert) bashunit::main::cmd_assert "$@" ;; watch) bashunit::main::cmd_watch "$@" ;; esac