#!/usr/bin/env bash # src/system/index.sh # src/system/check_os.sh _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 } 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 # src/system/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_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 } # src/system/io.sh function bashunit::io::file_size() { local bytes bytes=$(wc -c <"$1" 2>/dev/null | tr -d ' ') || bytes="" if [ -z "$bytes" ]; then bytes="unknown" fi printf '%s' "$bytes" } 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 } # src/util/index.sh # src/util/str.sh _BASHUNIT_STR_STRIPPED_OUT="" 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::str::strip_ansi_to_slot() { local input="$1" case "$input" in *\\* | *[[:cntrl:]]*) ;; *) _BASHUNIT_STR_STRIPPED_OUT=$input return ;; esac 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 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=$(printf '%s' "$input" | sed -E 's/\x1B\[[0-9;]*[mK]//g; s/[[:cntrl:]]//g') } 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}" local padding=$((width_padding - ${#right_word} - 1)) if ((padding < 0)); then padding=0 fi bashunit::str::strip_ansi_to_slot "$left_text" local clean_left_text=$_BASHUNIT_STR_STRIPPED_OUT local is_truncated=false 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 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 [ "$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}" ((++j)) elif [ "$char" = "$original_char" ]; then result_left_text="$result_left_text$char" ((++i)) ((++j)) else ((++j)) fi done result_left_text="$result_left_text..." remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1 - 3)) else result_left_text="$left_text" remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1)) fi if [ $remaining_space -lt 0 ]; then remaining_space=0 fi printf "%s%${remaining_space}s %s\n" "$result_left_text" "" "$right_word" } function bashunit::str::html_escape() { printf '%s' "$1" | awk '{ gsub(/&/, "\\&") gsub(//, "\\>") gsub(/"/, "\\"") print }' } # src/util/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 expr=$(echo "$expr" | sed -E 's/([0-9]+)\.[0-9]+/\1/g') ;; esac 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" } _BASHUNIT_MATH_PADDED_OUT="" function bashunit::math::pad_to_slot() { local value=$1 local places=$2 case "$value" in *.*) ;; *) value="${value}." ;; esac local frac=${value#*.} while [ ${#frac} -lt "$places" ]; do frac="${frac}0" done _BASHUNIT_MATH_PADDED_OUT="${value%%.*}.$frac" } _BASHUNIT_MATH_DECIMALS_OUT=0 function bashunit::math::decimals_to_slot() { local frac case "$1" in *.*) frac=${1#*.} _BASHUNIT_MATH_DECIMALS_OUT=${#frac} ;; *) _BASHUNIT_MATH_DECIMALS_OUT=0 ;; esac } _BASHUNIT_MATH_SCALED_L_OUT="" _BASHUNIT_MATH_SCALED_R_OUT="" function bashunit::math::scale_pair_to_slots() { local left=$1 right=$2 case "$left$right" in '' | *[!0-9.+-]* | *e* | *E*) return 1 ;; esac local left_sign=1 right_sign=1 case "$left" in -*) left_sign=-1 left=${left#-} ;; +*) left=${left#+} ;; esac case "$right" in -*) right_sign=-1 right=${right#-} ;; +*) right=${right#+} ;; esac case "$left$right" in *-* | *+*) return 1 ;; esac local left_int left_frac right_int right_frac case "$left" in *.*) left_int=${left%%.*} left_frac=${left#*.} ;; *) left_int=$left left_frac="" ;; esac case "$right" in *.*) right_int=${right%%.*} right_frac=${right#*.} ;; *) right_int=$right right_frac="" ;; esac case "$left_int$left_frac$right_int$right_frac" in *.*) return 1 ;; esac left_int=${left_int:-0} right_int=${right_int:-0} while [ ${#left_frac} -lt ${#right_frac} ]; do left_frac="${left_frac}0"; done while [ ${#right_frac} -lt ${#left_frac} ]; do right_frac="${right_frac}0"; done if [ $((${#left_int} + ${#left_frac})) -gt 18 ] || [ $((${#right_int} + ${#right_frac})) -gt 18 ]; then return 1 fi while [ ${#left_int} -gt 1 ]; do case "$left_int" in 0*) left_int=${left_int#0} ;; *) break ;; esac done while [ ${#right_int} -gt 1 ]; do case "$right_int" in 0*) right_int=${right_int#0} ;; *) break ;; esac done local left_frac_value=${left_frac:-0} right_frac_value=${right_frac:-0} while [ ${#left_frac_value} -gt 1 ]; do case "$left_frac_value" in 0*) left_frac_value=${left_frac_value#0} ;; *) break ;; esac done while [ ${#right_frac_value} -gt 1 ]; do case "$right_frac_value" in 0*) right_frac_value=${right_frac_value#0} ;; *) break ;; esac done local power=1 i=0 while [ "$i" -lt ${#left_frac} ]; do power=$((power * 10)) i=$((i + 1)) done _BASHUNIT_MATH_SCALED_L_OUT=$((left_sign * (left_int * power + left_frac_value))) _BASHUNIT_MATH_SCALED_R_OUT=$((right_sign * (right_int * power + right_frac_value))) } function bashunit::math::is_le() { local left="$1" local right="$2" if bashunit::math::scale_pair_to_slots "$left" "$right"; then [ "$_BASHUNIT_MATH_SCALED_L_OUT" -le "$_BASHUNIT_MATH_SCALED_R_OUT" ] return fi left=${left#+} right=${right#+} if bashunit::dependencies::has_bc; then [ "$(echo "$left <= $right" | bc)" = "1" ] return fi if bashunit::dependencies::has_awk; then awk -v a="$left" -v b="$right" 'BEGIN { exit !(a <= b) }' return fi left=$(echo "$left" | sed -E 's/([0-9]+)\.[0-9]+/\1/g') right=$(echo "$right" | sed -E 's/([0-9]+)\.[0-9]+/\1/g') [ "$left" -le "$right" ] } 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 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 } # src/util/clock.sh _BASHUNIT_CLOCK_NOW_IMPL="" function bashunit::clock::_choose_impl() { local shell_time local attempts_count=0 local attempts 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 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) case "$result" in '' | *[!0-9]*) ;; *) _BASHUNIT_CLOCK_NOW_IMPL="date" return 0 ;; esac fi 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 attempts[attempts_count]="Python" attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_python; then _BASHUNIT_CLOCK_NOW_IMPL="python" return 0 fi attempts[attempts_count]="Node" attempts_count=$((attempts_count + 1)) if bashunit::dependencies::has_node; then _BASHUNIT_CLOCK_NOW_IMPL="node" return 0 fi 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 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 printf "bashunit::clock::now implementations tried: %s\n" "${attempts[*]}" >&2 echo "" return 1 } 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="" function bashunit::clock::now_to_slot() { if [ -z "$_BASHUNIT_CLOCK_NOW_IMPL" ]; then _BASHUNIT_CLOCK_NOW_OUT="" bashunit::clock::_choose_impl || return 1 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) local shell_time="${EPOCHREALTIME:-}" local seconds="${shell_time%%[.,]*}" local microseconds="${shell_time#*[.,]}" if [ "$seconds" = "$shell_time" ]; then microseconds="" fi 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() { [ -n "${EPOCHREALTIME+x}" ] && [ -n "$EPOCHREALTIME" ] && 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::init() { _BASHUNIT_START_TIME=$(bashunit::clock::now) } # src/api/index.sh # src/api/globals.sh set -euo pipefail 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::is_command_available() { command -v "$1" >/dev/null 2>&1 } function bashunit::temp_file() { local prefix=${1:-bashunit} local test_prefix="" if [ -n "${BASHUNIT_CURRENT_TEST_ID:-}" ]; then test_prefix="${BASHUNIT_CURRENT_TEST_ID}_" elif [ -n "${BASHUNIT_CURRENT_SCRIPT_ID:-}" ]; then 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 test_prefix="${BASHUNIT_CURRENT_TEST_ID}_" elif [ -n "${BASHUNIT_CURRENT_SCRIPT_ID:-}" ]; then 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 local matches matches=("$BASHUNIT_TEMP_DIR/${BASHUNIT_CURRENT_TEST_ID}"_*) 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 } function bashunit::print_line() { local length="${1:-70}" local char="${2:--}" printf '%*s\n' "$length" '' | tr ' ' "$char" } function bashunit::data_set() { local arg local first=true for arg in "$@"; do if [ "$first" = true ]; then 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 printf " ''\n" } # src/api/skip_todo.sh function bashunit::skip::__mark() { local reason=${1-} local depth=${2:-2} local label label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[$depth]:-}")" bashunit::skip::__mark_with_label "$label" "$reason" } function bashunit::skip::__mark_with_label() { bashunit::console_results::print_skipped_test "${1}" "${2-}" bashunit::state::add_assertions_skipped } function bashunit::skip::__mark_and_stop() { bashunit::skip::__mark "${1-}" 3 exit 0 } function bashunit::skip() { bashunit::skip::__mark "${1-}" 2 } function bashunit::skip_if() { local condition=${1-} local reason=${2-} if eval "$condition"; then bashunit::skip::__mark_and_stop "$reason" fi } function bashunit::skip_unless() { local condition=${1-} local reason=${2-} if eval "$condition"; then return 0 fi bashunit::skip::__mark_and_stop "$reason" } function bashunit::skip_unless_command() { local cmd for cmd in "$@"; do if ! command -v "$cmd" >/dev/null 2>&1; then bashunit::skip::__mark_and_stop "requires $cmd" fi done } function bashunit::skip_on() { local os=${1-} local reason=${2-} local matches=false case "$os" in windows) if bashunit::check_os::is_windows; then matches=true fi ;; macos) if bashunit::check_os::is_macos; then matches=true fi ;; linux) if bashunit::check_os::is_linux; then matches=true fi ;; *) bashunit::assert::usage_error_detail "bashunit::skip_on" \ "accepts windows, macos or linux, got '$os'" return 1 ;; esac if [ "$matches" = true ]; then bashunit::skip::__mark_and_stop "$reason" fi } function bashunit::todo() { local pending=${1-} local label label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")" bashunit::console_results::print_incomplete_test "${label}" "${pending}" bashunit::state::add_assertions_incomplete } # src/api/test_title.sh function bashunit::set_test_title() { bashunit::state::set_test_title "$1" } # src/api/bashunit.sh function bashunit::assertion_failed() { bashunit::assert::should_skip && return 0 local expected=$1 local actual=$2 local failure_condition_message=${3:-"but got "} local label=${4:-} bashunit::assert::fail_with "$label" "${expected}" \ "$failure_condition_message" "${actual}" } function bashunit::assertion_passed() { bashunit::assert::should_skip && return 0 bashunit::state::add_assertions_passed } function bashunit::assert_that() { bashunit::assert::should_skip && return 0 local expected=$1 local actual=$2 shift 2 if "$@"; then bashunit::state::add_assertions_passed return 0 fi bashunit::assert::fail_with "" "$expected" "but got " "$actual" return 1 } # src/config/index.sh # src/config/parallel.sh 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() { local target="$TEMP_DIR_PARALLEL_TEST_SUITE" target="${target%/}" case "$target" in */bashunit/parallel/*/?*) rm -rf "$target" return 0 ;; *) 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" } _BASHUNIT_PARALLEL_ENABLED="" 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) } 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 bashunit::parallel::_compute_enabled } # src/config/env.sh 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 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#*=} case "$key" in '' | *[!A-Za-z0-9_]* | [0-9]*) continue ;; esac case "$val" in \"*\") val=${val#\"} val=${val%\"} ;; \'*\') val=${val#\'} val=${val%\'} ;; esac eval "export $key=\"\${$key:-\$val}\"" done <"$file" } function bashunit::env::warn_deprecated() { [ "${BASHUNIT_NO_DEPRECATION_WARNINGS:-false}" = "true" ] && return 0 printf "%sDeprecated: %s. Use %s instead.%s\n" \ "${_BASHUNIT_COLOR_SKIPPED:-}" "$1" "$2" "${_BASHUNIT_COLOR_DEFAULT:-}" >&2 } function bashunit::env::positive_int_or_default() { local value="$1" local default="$2" case "$value" in '' | *[!0-9]* | 0) echo "$default" ;; *) echo "$value" ;; esac } if [ "${BASHUNIT_SKIP_ENV_FILE:-false}" != "true" ]; then bashunit::env::load_config_file ".bashunitrc" if [ -f ".env" ]; then _bashunit_env_preserved="" for _bashunit_env_name in $(compgen -v BASHUNIT_ 2>/dev/null || true); do eval "_bashunit_env_value=\${$_bashunit_env_name}" if [ -n "$_bashunit_env_value" ]; then _bashunit_env_preserved="$_bashunit_env_preserved $_bashunit_env_name" eval "_bashunit_env_saved_$_bashunit_env_name=\$_bashunit_env_value" fi done set -o allexport source .env set +o allexport for _bashunit_env_name in $_bashunit_env_preserved; do eval "_bashunit_env_value=\${$_bashunit_env_name}" if [ -z "$_bashunit_env_value" ]; then eval "export $_bashunit_env_name=\$_bashunit_env_saved_$_bashunit_env_name" fi eval "unset _bashunit_env_saved_$_bashunit_env_name" done unset _bashunit_env_preserved _bashunit_env_name _bashunit_env_value fi fi _BASHUNIT_DEPRECATED_ALIASES="DEFAULT_PATH DEV_LOG BOOTSTRAP BOOTSTRAP_ARGS LOG_JUNIT LOG_GHA REPORT_HTML REPORT_TAP REPORT_JSON WATCH_INTERVAL COVERAGE COVERAGE_PATHS COVERAGE_EXCLUDE COVERAGE_REPORT COVERAGE_REPORT_HTML COVERAGE_MIN COVERAGE_THRESHOLD_LOW COVERAGE_THRESHOLD_HIGH PARALLEL_RUN SHOW_HEADER HEADER_ASCII_ART SIMPLE_OUTPUT STOP_ON_FAILURE SHOW_EXECUTION_TIME VERBOSE BENCH_MODE NO_OUTPUT INTERNAL_LOG SHOW_SKIPPED SHOW_INCOMPLETE STRICT_MODE STOP_ON_ASSERTION_FAILURE SKIP_ENV_FILE LOGIN_SHELL FAILURES_ONLY SHOW_OUTPUT_ON_FAILURE NO_DIFF NO_PROGRESS OUTPUT_FORMAT FAIL_ON_RISKY PROFILE PROFILE_COUNT TEST_TIMEOUT" _bashunit_deprecated_in_use="" for _bashunit_alias in $_BASHUNIT_DEPRECATED_ALIASES; do eval "_bashunit_alias_prefixed=\${BASHUNIT_$_bashunit_alias+set}" [ -n "$_bashunit_alias_prefixed" ] && continue eval "_bashunit_alias_value=\${$_bashunit_alias:-}" [ -n "$_bashunit_alias_value" ] && _bashunit_deprecated_in_use="$_bashunit_deprecated_in_use $_bashunit_alias" done unset _bashunit_alias _bashunit_alias_prefixed _bashunit_alias_value if [ -n "$_bashunit_deprecated_in_use" ]; then for _bashunit_alias in $_bashunit_deprecated_in_use; do bashunit::env::warn_deprecated \ "the unprefixed \`$_bashunit_alias\`" "\`BASHUNIT_$_bashunit_alias\`" done unset _bashunit_alias 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="" _BASHUNIT_DEFAULT_REPORT_MD="" _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_REPORT_COBERTURA="" _BASHUNIT_DEFAULT_COVERAGE_MIN="" _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW="50" _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH="80" _BASHUNIT_DEFAULT_COVERAGE_SHOW_LINE_HITS="false" _BASHUNIT_DEFAULT_COVERAGE_SHOW_FUNCTIONS="false" _BASHUNIT_DEFAULT_COVERAGE_SHOW_UNCOVERED="false" _BASHUNIT_DEFAULT_COVERAGE_ENGINE="auto" _BASHUNIT_DEFAULT_COVERAGE_DIFF="" : "${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}}" _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") : "${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}}" : "${BASHUNIT_COVERAGE_SHOW_LINE_HITS:=$_BASHUNIT_DEFAULT_COVERAGE_SHOW_LINE_HITS}" : "${BASHUNIT_COVERAGE_SHOW_FUNCTIONS:=$_BASHUNIT_DEFAULT_COVERAGE_SHOW_FUNCTIONS}" : "${BASHUNIT_COVERAGE_SHOW_UNCOVERED:=$_BASHUNIT_DEFAULT_COVERAGE_SHOW_UNCOVERED}" : "${BASHUNIT_COVERAGE_ENGINE:=$_BASHUNIT_DEFAULT_COVERAGE_ENGINE}" : "${BASHUNIT_COVERAGE_REPORT_COBERTURA:=$_BASHUNIT_DEFAULT_COVERAGE_REPORT_COBERTURA}" : "${BASHUNIT_COVERAGE_DIFF:=$_BASHUNIT_DEFAULT_COVERAGE_DIFF}" _BASHUNIT_DEFAULT_PARALLEL_RUN="false" _BASHUNIT_DEFAULT_PARALLEL_JOBS="0" _BASHUNIT_DEFAULT_SHOW_HEADER="true" _BASHUNIT_DEFAULT_HEADER_ASCII_ART="false" _BASHUNIT_DEFAULT_SIMPLE_OUTPUT="false" _BASHUNIT_DEFAULT_STOP_ON_FAILURE="false" _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_SNAPSHOT_PRUNE="false" _BASHUNIT_DEFAULT_BENCH_BASELINE="" _BASHUNIT_DEFAULT_BENCH_BASELINE_TOLERANCE="10" _BASHUNIT_DEFAULT_BENCH_BASELINE_UPDATE="" _BASHUNIT_DEFAULT_BENCH_REPORT_JSON="" _BASHUNIT_DEFAULT_BENCH_REPORT_JUNIT="" _BASHUNIT_DEFAULT_SANDBOX="false" _BASHUNIT_DEFAULT_SANDBOX_ALLOW="" _BASHUNIT_DEFAULT_PROFILE="false" _BASHUNIT_DEFAULT_PROFILE_COUNT="10" _BASHUNIT_DEFAULT_TEST_TIMEOUT="0" _BASHUNIT_DEFAULT_RETRY="0" _BASHUNIT_DEFAULT_RANDOM_ORDER="false" _BASHUNIT_DEFAULT_SEED="" _BASHUNIT_DEFAULT_SHARD_INDEX="" _BASHUNIT_DEFAULT_SHARD_TOTAL="" _BASHUNIT_DEFAULT_RERUN_FAILED="false" _BASHUNIT_DEFAULT_GHA_ANNOTATIONS="auto" _BASHUNIT_DEFAULT_REPEAT="1" _BASHUNIT_DEFAULT_FAIL_ON_FLAKY="false" _BASHUNIT_DEFAULT_ORDER_BY="defined" _BASHUNIT_DEFAULT_CHANGED="false" _BASHUNIT_DEFAULT_CHANGED_REF="" _BASHUNIT_DEFAULT_EXCLUDE_FILTER="" _BASHUNIT_DEFAULT_LIST_TESTS="false" _BASHUNIT_DEFAULT_LIST_FORMAT="text" _BASHUNIT_DEFAULT_SNAPSHOT_UPDATE="false" _BASHUNIT_DEFAULT_SNAPSHOT_CREATE="true" _BASHUNIT_DEFAULT_SNAPSHOT_REPORT_UNUSED="false" : "${BASHUNIT_PARALLEL_RUN:=${PARALLEL_RUN:=$_BASHUNIT_DEFAULT_PARALLEL_RUN}}" : "${BASHUNIT_PARALLEL_JOBS:=$_BASHUNIT_DEFAULT_PARALLEL_JOBS}" : "${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_SNAPSHOT_PRUNE:=$_BASHUNIT_DEFAULT_SNAPSHOT_PRUNE}" : "${BASHUNIT_BENCH_BASELINE:=$_BASHUNIT_DEFAULT_BENCH_BASELINE}" : "${BASHUNIT_BENCH_BASELINE_TOLERANCE:=$_BASHUNIT_DEFAULT_BENCH_BASELINE_TOLERANCE}" : "${BASHUNIT_BENCH_BASELINE_UPDATE:=$_BASHUNIT_DEFAULT_BENCH_BASELINE_UPDATE}" : "${BASHUNIT_BENCH_REPORT_JSON:=$_BASHUNIT_DEFAULT_BENCH_REPORT_JSON}" : "${BASHUNIT_BENCH_REPORT_JUNIT:=$_BASHUNIT_DEFAULT_BENCH_REPORT_JUNIT}" : "${BASHUNIT_SANDBOX:=$_BASHUNIT_DEFAULT_SANDBOX}" : "${BASHUNIT_SANDBOX_ALLOW:=$_BASHUNIT_DEFAULT_SANDBOX_ALLOW}" : "${BASHUNIT_PROFILE:=${PROFILE:=$_BASHUNIT_DEFAULT_PROFILE}}" : "${BASHUNIT_PROFILE_COUNT:=${PROFILE_COUNT:=$_BASHUNIT_DEFAULT_PROFILE_COUNT}}" : "${BASHUNIT_TEST_TIMEOUT:=${TEST_TIMEOUT:=$_BASHUNIT_DEFAULT_TEST_TIMEOUT}}" : "${BASHUNIT_RETRY:=$_BASHUNIT_DEFAULT_RETRY}" : "${BASHUNIT_RANDOM_ORDER:=$_BASHUNIT_DEFAULT_RANDOM_ORDER}" : "${BASHUNIT_SEED:=$_BASHUNIT_DEFAULT_SEED}" : "${BASHUNIT_ORDER_BY:=$_BASHUNIT_DEFAULT_ORDER_BY}" : "${BASHUNIT_FAIL_ON_FLAKY:=$_BASHUNIT_DEFAULT_FAIL_ON_FLAKY}" : "${BASHUNIT_REPEAT:=$_BASHUNIT_DEFAULT_REPEAT}" : "${BASHUNIT_GHA_ANNOTATIONS:=$_BASHUNIT_DEFAULT_GHA_ANNOTATIONS}" : "${BASHUNIT_REPORT_MD:=$_BASHUNIT_DEFAULT_REPORT_MD}" if [ -n "${_BASHUNIT_GHA_ANNOTATIONS_CLAIMED:-}" ]; then _BASHUNIT_IS_OUTERMOST_RUN=false else _BASHUNIT_IS_OUTERMOST_RUN=true fi export _BASHUNIT_GHA_ANNOTATIONS_CLAIMED=1 : "${BASHUNIT_SHARD_INDEX:=$_BASHUNIT_DEFAULT_SHARD_INDEX}" : "${BASHUNIT_SHARD_TOTAL:=$_BASHUNIT_DEFAULT_SHARD_TOTAL}" : "${BASHUNIT_RERUN_FAILED:=$_BASHUNIT_DEFAULT_RERUN_FAILED}" : "${BASHUNIT_CHANGED:=$_BASHUNIT_DEFAULT_CHANGED}" : "${BASHUNIT_CHANGED_REF:=$_BASHUNIT_DEFAULT_CHANGED_REF}" : "${BASHUNIT_EXCLUDE_FILTER:=$_BASHUNIT_DEFAULT_EXCLUDE_FILTER}" : "${BASHUNIT_LIST_TESTS:=$_BASHUNIT_DEFAULT_LIST_TESTS}" : "${BASHUNIT_LIST_FORMAT:=$_BASHUNIT_DEFAULT_LIST_FORMAT}" : "${BASHUNIT_SNAPSHOT_UPDATE:=$_BASHUNIT_DEFAULT_SNAPSHOT_UPDATE}" : "${BASHUNIT_SNAPSHOT_CREATE:=$_BASHUNIT_DEFAULT_SNAPSHOT_CREATE}" : "${BASHUNIT_SNAPSHOT_REPORT_UNUSED:=$_BASHUNIT_DEFAULT_SNAPSHOT_REPORT_UNUSED}" 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" ] } function bashunit::env::is_test_timeout_enabled() { case "${BASHUNIT_TEST_TIMEOUT:-0}" in '' | *[!0-9]*) return 1 ;; esac [ "${BASHUNIT_TEST_TIMEOUT:-0}" -gt 0 ] } function bashunit::env::test_timeout_secs() { printf '%s' "${BASHUNIT_TEST_TIMEOUT:-0}" } _BASHUNIT_RETRY_VALIDATED=0 _BASHUNIT_REPEAT_VALIDATED=1 function bashunit::env::resolve_repeat_count() { case "${BASHUNIT_REPEAT:-1}" in '' | *[!0-9]* | 0) _BASHUNIT_REPEAT_VALIDATED=1 ;; *) _BASHUNIT_REPEAT_VALIDATED="${BASHUNIT_REPEAT:-1}" ;; esac } 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" ] || [ "${BASHUNIT_ORDER_BY:-defined}" = "random" ] } function bashunit::env::is_defects_order_enabled() { [ "${BASHUNIT_ORDER_BY:-defined}" = "defects" ] } 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::is_changed_enabled() { [ "${BASHUNIT_CHANGED:-false}" = "true" ] } 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 } 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::current_timestamp() { date +"%Y-%m-%d %H:%M:%S" } 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::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() { [ "${BASHUNIT_NO_DIFF:-}" != "true" ] } 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_json_output_enabled() { [ "$BASHUNIT_OUTPUT_FORMAT" = "json" ] } function bashunit::env::is_junit_output_enabled() { [ "$BASHUNIT_OUTPUT_FORMAT" = "junit" ] } function bashunit::env::is_machine_output_enabled() { case "$BASHUNIT_OUTPUT_FORMAT" in tap | json | junit) return 0 ;; esac return 1 } function bashunit::env::is_snapshot_prune_enabled() { [ "${BASHUNIT_SNAPSHOT_PRUNE:-false}" = "true" ] } function bashunit::env::is_snapshot_report_unused_enabled() { [ "$BASHUNIT_SNAPSHOT_REPORT_UNUSED" = "true" ] } function bashunit::env::is_snapshot_create_enabled() { [ "$BASHUNIT_SNAPSHOT_CREATE" = "true" ] } function bashunit::env::is_snapshot_update_enabled() { [ "$BASHUNIT_SNAPSHOT_UPDATE" = "true" ] } function bashunit::env::is_list_enabled() { [ "$BASHUNIT_LIST_TESTS" = "true" ] } function bashunit::env::is_fail_on_risky_enabled() { [ "$BASHUNIT_FAIL_ON_RISKY" = "true" ] } function bashunit::env::is_sandbox_enabled() { [ "${BASHUNIT_SANDBOX:-false}" = "true" ] } function bashunit::env::should_print_gha_annotations() { case "${BASHUNIT_GHA_ANNOTATIONS:-auto}" in never) return 1 ;; always) return 0 ;; esac [ "${_BASHUNIT_IS_OUTERMOST_RUN:-true}" = true ] && [ "${GITHUB_ACTIONS:-}" = "true" ] && ! bashunit::env::is_machine_output_enabled } function bashunit::env::should_append_step_summary() { [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 1 [ "${_BASHUNIT_IS_OUTERMOST_RUN:-true}" = true ] } function bashunit::env::is_fail_on_flaky_enabled() { [ "${BASHUNIT_FAIL_ON_FLAKY:-false}" = "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 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 echo "${cols:-100}" } function bashunit::env::print_verbose() { bashunit::internal_log "Printing verbose environment variables" local IFS=$' \t\n' 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" "BASHUNIT_COVERAGE_ENGINE" ) 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 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)" AWK="$(command -v awk)" _BASHUNIT_RUN_OUTPUT_DIR="${TMPDIR:-/tmp}/bashunit/run/${_BASHUNIT_OS:-Unknown}/$(bashunit::random_str 8)" _BASHUNIT_RUN_DIR_VANISHED=false 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" WORKER_STDERR_OUTPUT_PREFIX="$_BASHUNIT_RUN_OUTPUT_DIR/worker-stderr" RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed" SNAPSHOT_USED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/snapshots-used" REPORTS_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/reports" BASHUNIT_TEMP_DIR="${TMPDIR:-/tmp}/bashunit/tmp" function bashunit::env::create_scratch_dirs() { local run_dir=$1 local temp_dir=$2 mkdir -p "$run_dir" "$temp_dir" || true local dir for dir in "$run_dir" "$temp_dir"; do if [ ! -d "$dir" ]; then printf 'bashunit: cannot create the scratch directory: %s\n' "$dir" >&2 printf 'bashunit: set TMPDIR to a writable location and try again.\n' >&2 return 1 fi done } bashunit::env::create_scratch_dirs "$_BASHUNIT_RUN_OUTPUT_DIR" "$BASHUNIT_TEMP_DIR" || exit 1 function bashunit::env::cleanup_run_output_dir() { local target="$_BASHUNIT_RUN_OUTPUT_DIR" target="${target%/}" case "$target" in */bashunit/run/*/?*) rm -rf "$target" return 0 ;; *) bashunit::internal_log "env::cleanup_run_output_dir" "refused unsafe path:$target" return 1 ;; esac } _BASHUNIT_LOADING_BOOTSTRAP="" function bashunit::env::report_unfinished_bootstrap() { if [ -n "${_BASHUNIT_LOADING_BOOTSTRAP:-}" ]; then printf "%sError: the bootstrap file did not load: '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$_BASHUNIT_LOADING_BOOTSTRAP" \ "${_BASHUNIT_COLOR_DEFAULT}" >&2 printf "%s\n" "It ended the shell before any test ran (a syntax error, or an 'exit')." >&2 _BASHUNIT_LOADING_BOOTSTRAP="" exit 1 fi } trap 'bashunit::env::report_unfinished_bootstrap; 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 # src/config/rerun.sh _BASHUNIT_RERUN_ENTRIES="" function bashunit::rerun::cache_file() { echo "${BASHUNIT_RERUN_CACHE_DIR:-.bashunit}/last-failed" } function bashunit::rerun::is_enabled() { [ "${BASHUNIT_RERUN_FAILED:-false}" = true ] } 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 } 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 } 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" ] } 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 }' } function bashunit::rerun::allows() { local file=$1 local fn=$2 case " $_BASHUNIT_RERUN_ENTRIES " in *" $file:$fn "*) return 0 ;; esac return 1 } function bashunit::rerun::order_files() { local recorded_files recorded_files="$(bashunit::rerun::files)" if [ -z "$recorded_files" ]; then [ "$#" -gt 0 ] && printf '%s\n' "$@" return 0 fi local file recorded while IFS= read -r recorded; do [ -z "$recorded" ] && continue for file in "$@"; do if [ "$file" = "$recorded" ]; then printf '%s\n' "$file" break fi done done <&2 exit 1 } function bashunit::suites::load() { local file=${1:-.bashunitrc} if [ "$file" = "$_BASHUNIT_SUITES_LOADED_FILE" ]; then return 0 fi _BASHUNIT_SUITES_LOADED_FILE="$file" _BASHUNIT_SUITE_NAMES=() _BASHUNIT_SUITE_PATHS=() _BASHUNIT_SUITE_ARGS=() [ -f "$file" ] || return 0 local line raw key val index=-1 while IFS= read -r line || [ -n "$line" ]; do raw=$line line=${line#"${line%%[![:space:]]*}"} line=${line%"${line##*[![:space:]]}"} case "$line" in '' | '#'* | ';'*) continue ;; esac case "$line" in '['*']') case "$line" in '[suite:'*']') local name=${line#\[suite:} name=${name%\]} name=${name#"${name%%[![:space:]]*}"} name=${name%"${name##*[![:space:]]}"} if [ -z "$name" ]; then bashunit::suites::_abort "$file" "a suite section needs a name" "$raw" fi index=$((${#_BASHUNIT_SUITE_NAMES[@]})) _BASHUNIT_SUITE_NAMES[index]="$name" _BASHUNIT_SUITE_PATHS[index]="" _BASHUNIT_SUITE_ARGS[index]="" ;; *) index=-1 ;; esac continue ;; esac if [ "$index" -lt 0 ]; then continue fi case "$line" in *=*) ;; *) bashunit::suites::_abort "$file" "a suite entry must be 'key = value'" "$raw" ;; esac key=${line%%=*} val=${line#*=} key=${key%"${key##*[![:space:]]}"} val=${val#"${val%%[![:space:]]*}"} val=${val%"${val##*[![:space:]]}"} case "$val" in \"*\") val=${val#\"} val=${val%\"} ;; \'*\') val=${val#\'} val=${val%\'} ;; esac if [ "$key" = "paths" ] || [ "$key" = "path" ]; then _BASHUNIT_SUITE_PATHS[index]="$val" continue fi key=$(printf '%s' "$key" | tr '_' '-') case "$key" in '' | *[!a-z0-9-]*) bashunit::suites::_abort "$file" "a suite option must be a long flag name" "$raw" ;; esac case "$val" in false) continue ;; true) bashunit::suites::_append "$index" "--$key" ;; *) bashunit::suites::_append "$index" "--$key" "$val" ;; esac done <"$file" } function bashunit::suites::_append() { local index=$1 shift local current=${_BASHUNIT_SUITE_ARGS[index]} local entry for entry in "$@"; do if [ -z "$current" ]; then current="$entry" else current="$current $entry" fi done _BASHUNIT_SUITE_ARGS[index]="$current" } function bashunit::suites::names() { local i=0 local total=${#_BASHUNIT_SUITE_NAMES[@]} local seen="" local name while [ "$i" -lt "$total" ]; do name="${_BASHUNIT_SUITE_NAMES[i]}" case "$seen" in *"|$name|"*) ;; *) seen="$seen|$name|" printf '%s\n' "$name" ;; esac i=$((i + 1)) done } function bashunit::suites::resolve() { local wanted=$1 local i=0 local total=${#_BASHUNIT_SUITE_NAMES[@]} while [ "$i" -lt "$total" ]; do if [ "${_BASHUNIT_SUITE_NAMES[i]}" = "$wanted" ]; then _BASHUNIT_SUITE_PATHS_OUT="${_BASHUNIT_SUITE_PATHS[i]}" _BASHUNIT_SUITE_ARGS_OUT="${_BASHUNIT_SUITE_ARGS[i]}" return 0 fi i=$((i + 1)) done local defined defined=$(bashunit::suites::names | tr '\n' ' ') defined=${defined%" "} if [ -z "$defined" ]; then defined="none defined in .bashunitrc" fi printf "%sError: unknown suite '%s'. Defined: %s.%s\n" \ "${_BASHUNIT_COLOR_FAILED:-}" "$wanted" "$defined" "${_BASHUNIT_COLOR_DEFAULT:-}" >&2 exit 1 } # src/coverage/index.sh # src/coverage/config.sh _BASHUNIT_COVERAGE_DATA_FILE="${_BASHUNIT_COVERAGE_DATA_FILE:-}" _BASHUNIT_COVERAGE_TRACKED_FILES="${_BASHUNIT_COVERAGE_TRACKED_FILES:-}" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-}" _BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" _BASHUNIT_COVERAGE_ENGINE_RESOLVED="${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" _BASHUNIT_COVERAGE_IS_PARALLEL="" 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 local file_basename file_basename=$(basename "$test_file") local source_name="${file_basename%_test.sh}" [ "$source_name" = "$file_basename" ] && source_name="${file_basename%Test.sh}" [ "$source_name" = "$file_basename" ] && continue local found_file while IFS= read -r -d '' found_file; do 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 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 if [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ]; then export BASHUNIT_COVERAGE=false return 0 fi 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" : >"$_BASHUNIT_COVERAGE_DATA_FILE" : >"$_BASHUNIT_COVERAGE_TRACKED_FILES" _BASHUNIT_COVERAGE_SEEDED=false : >"$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" : >"$_BASHUNIT_COVERAGE_TEST_HITS_FILE" _BASHUNIT_COVERAGE_DISKCACHE_FILE="" _BASHUNIT_COVERAGE_DISKCACHE_KEYS=() _BASHUNIT_COVERAGE_DISKCACHE_VALUES=() _BASHUNIT_COVERAGE_DISKCACHE_COUNT=0 bashunit::coverage::build_trap_glob export _BASHUNIT_COVERAGE_TRAP_GLOB bashunit::coverage::invalidate_hits_aggregation bashunit::coverage::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_FILE_" _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::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_" _BASHUNIT_COVERAGE_ENGINE_RESOLVED=$(bashunit::coverage::resolve_engine) export _BASHUNIT_COVERAGE_DATA_FILE export _BASHUNIT_COVERAGE_TRACKED_FILES export _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE export _BASHUNIT_COVERAGE_TEST_HITS_FILE export _BASHUNIT_COVERAGE_ENGINE_RESOLVED } function bashunit::coverage::xtrace_is_supported() { if [ "${BASH_VERSINFO[0]}" -gt 4 ]; then return 0 fi [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 1 ] } function bashunit::coverage::resolve_engine() { case "${BASHUNIT_COVERAGE_ENGINE:-auto}" in xtrace | auto) if bashunit::coverage::xtrace_is_supported; then echo "xtrace" else echo "trap" fi ;; *) echo "trap" ;; esac } function bashunit::coverage::engine_in_use() { if [ -n "${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" ]; then echo "$_BASHUNIT_COVERAGE_ENGINE_RESOLVED" return 0 fi bashunit::coverage::resolve_engine } function bashunit::coverage::engine_was_downgraded() { if [ "${BASHUNIT_COVERAGE_ENGINE:-auto}" != "xtrace" ]; then return 1 fi if bashunit::coverage::xtrace_is_supported; then return 1 fi return 0 } # src/coverage/paths.sh _BASHUNIT_COVERAGE_LOOKUP_OUT="" _BASHUNIT_COVERAGE_LOOKUP_KEY_OUT="" function bashunit::coverage::lookup_key_to_slot() { _BASHUNIT_COVERAGE_LOOKUP_KEY_OUT="$1${2//[^a-zA-Z0-9]/_}" } function bashunit::coverage::lookup_put() { bashunit::coverage::lookup_key_to_slot "$1" "$2" local key=$_BASHUNIT_COVERAGE_LOOKUP_KEY_OUT eval "${key}_PATH=\$2" eval "${key}_VALUE=\$3" } function bashunit::coverage::lookup_get() { bashunit::coverage::lookup_key_to_slot "$1" "$2" local path_var="${_BASHUNIT_COVERAGE_LOOKUP_KEY_OUT}_PATH" local value_var="${_BASHUNIT_COVERAGE_LOOKUP_KEY_OUT}_VALUE" _BASHUNIT_COVERAGE_LOOKUP_OUT="" if [ "${!path_var:-}" != "$2" ]; then return 1 fi _BASHUNIT_COVERAGE_LOOKUP_OUT="${!value_var:-}" return 0 } function bashunit::coverage::reset_lookup_namespace() { local name for name in $(compgen -v "$1" 2>/dev/null || true); do unset "$name" done } function bashunit::coverage::normalize_path() { local file="$1" [ -f "$file" ] || { builtin printf '%s' "$file" return 0 } local dir="${file%/*}" base="${file##*/}" if [ "$dir" = "$file" ]; then dir="." elif [ -z "$dir" ]; then dir="/" fi builtin printf '%s/%s' "$(cd "$dir" 2>/dev/null && pwd)" "$base" } function bashunit::coverage::get_tracked_files() { if [ ! -f "$_BASHUNIT_COVERAGE_TRACKED_FILES" ]; then return fi sort -u "$_BASHUNIT_COVERAGE_TRACKED_FILES" } _BASHUNIT_COVERAGE_TRAP_GLOB="" function bashunit::coverage::_single_quote() { local value=$1 local q="'" local esc="$q\\$q$q" printf "%s%s%s" "$q" "${value//$q/$esc}" "$q" } function bashunit::coverage::build_trap_glob() { _BASHUNIT_COVERAGE_TRAP_GLOB="" [ -n "${BASHUNIT_COVERAGE_PATHS:-}" ] || return 0 local glob="" local cwd cwd=$(pwd) local old_ifs="$IFS" IFS=',' local path resolved relative for path in $BASHUNIT_COVERAGE_PATHS; do [ -n "$path" ] || continue case "$path" in /*) resolved="$path" ;; *) resolved="$cwd/$path" ;; esac relative="$path" case "$relative" in "$cwd"/*) relative="${relative#"$cwd"/}" ;; esac local q_resolved q_relative q_resolved=$(bashunit::coverage::_single_quote "$resolved") q_relative=$(bashunit::coverage::_single_quote "$relative") if [ -n "$glob" ]; then glob="$glob|" fi glob="$glob${q_resolved}*|${q_relative}*|./${q_relative}*|*/${q_relative}/*|*/${q_relative}" done IFS="$old_ifs" _BASHUNIT_COVERAGE_TRAP_GLOB="$glob" } _BASHUNIT_COVERAGE_SEEDED=false function bashunit::coverage::seed_tracked_files() { if [ "$_BASHUNIT_COVERAGE_SEEDED" = true ]; then return 0 fi _BASHUNIT_COVERAGE_SEEDED=true [ -n "${BASHUNIT_COVERAGE_PATHS:-}" ] || return 0 [ -n "${_BASHUNIT_COVERAGE_TRACKED_FILES:-}" ] || return 0 local old_ifs="$IFS" IFS=',' local path for path in $BASHUNIT_COVERAGE_PATHS; do [ -n "$path" ] || continue IFS="$old_ifs" bashunit::coverage::_seed_one_path "$path" IFS=',' done IFS="$old_ifs" } function bashunit::coverage::_seed_one_path() { local path="$1" local root case "$path" in /*) root="$path" ;; *) root="$(pwd)/$path" ;; esac if [ -f "$root" ]; then root="$(bashunit::coverage::normalize_path "$root")" bashunit::coverage::_seed_emit "$root" return 0 fi [ -d "$root" ] || return 0 root="$(cd "$root" && pwd)" local file while IFS= read -r file; do [ -n "$file" ] || continue bashunit::coverage::_seed_emit "$file" done < <(find "$root" -type f -name '*.sh' 2>/dev/null) } function bashunit::coverage::_seed_emit() { local file="$1" local old_ifs="$IFS" IFS=',' local pattern for pattern in ${BASHUNIT_COVERAGE_EXCLUDE:-}; do case "$file" in *$pattern*) IFS="$old_ifs" return 0 ;; esac done IFS="$old_ifs" printf '%s\n' "$file" >>"$_BASHUNIT_COVERAGE_TRACKED_FILES" } _BASHUNIT_COVERAGE_DISKCACHE_FILE="" _BASHUNIT_COVERAGE_DISKCACHE_KEYS=() _BASHUNIT_COVERAGE_DISKCACHE_VALUES=() _BASHUNIT_COVERAGE_DISKCACHE_COUNT=0 function bashunit::coverage::_diskcache_load() { local cache_file="$1" [ "$_BASHUNIT_COVERAGE_DISKCACHE_FILE" = "$cache_file" ] && return 0 _BASHUNIT_COVERAGE_DISKCACHE_FILE="$cache_file" _BASHUNIT_COVERAGE_DISKCACHE_KEYS=() _BASHUNIT_COVERAGE_DISKCACHE_VALUES=() _BASHUNIT_COVERAGE_DISKCACHE_COUNT=0 [ -f "$cache_file" ] || return 0 local line idx=0 while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue _BASHUNIT_COVERAGE_DISKCACHE_KEYS[idx]="${line%:*}" _BASHUNIT_COVERAGE_DISKCACHE_VALUES[idx]="${line##*:}" idx=$((idx + 1)) done <"$cache_file" _BASHUNIT_COVERAGE_DISKCACHE_COUNT=$idx } _BASHUNIT_COVERAGE_DISKCACHE_OUT="" function bashunit::coverage::_diskcache_get() { local file="$1" idx=0 while [ "$idx" -lt "$_BASHUNIT_COVERAGE_DISKCACHE_COUNT" ]; do if [ "${_BASHUNIT_COVERAGE_DISKCACHE_KEYS[idx]}" = "$file" ]; then _BASHUNIT_COVERAGE_DISKCACHE_OUT="${_BASHUNIT_COVERAGE_DISKCACHE_VALUES[idx]}" return 0 fi idx=$((idx + 1)) done return 1 } function bashunit::coverage::_diskcache_put() { local cache_file="$1" file="$2" decision="$3" { [ -n "$cache_file" ] && [ -f "$cache_file" ]; } || return 0 echo "${file}:${decision}" >>"$cache_file" local idx="$_BASHUNIT_COVERAGE_DISKCACHE_COUNT" _BASHUNIT_COVERAGE_DISKCACHE_KEYS[idx]="$file" _BASHUNIT_COVERAGE_DISKCACHE_VALUES[idx]="$decision" _BASHUNIT_COVERAGE_DISKCACHE_COUNT=$((idx + 1)) } function bashunit::coverage::should_track() { local file="$1" [ -z "$file" ] && return 1 [ -z "$_BASHUNIT_COVERAGE_TRACKED_FILES" ] && return 1 local cache_file="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" if bashunit::parallel::is_enabled && [ -n "$cache_file" ]; then cache_file="${cache_file}.$$" if [ ! -f "$cache_file" ] && [ -d "${cache_file%/*}" ]; then : >"$cache_file" fi fi if [ -n "$cache_file" ]; then bashunit::coverage::_diskcache_load "$cache_file" if bashunit::coverage::_diskcache_get "$file"; then [ "$_BASHUNIT_COVERAGE_DISKCACHE_OUT" = "1" ] && return 0 || return 1 fi fi local normalized_file normalized_file=$(bashunit::coverage::normalize_path "$file") local old_ifs="$IFS" IFS=',' local pattern for pattern in $BASHUNIT_COVERAGE_EXCLUDE; do case "$normalized_file" in *$pattern*) IFS="$old_ifs" bashunit::coverage::_diskcache_put "$cache_file" "$file" "0" return 1 ;; esac done local matched=false local path for path in $BASHUNIT_COVERAGE_PATHS; do local resolved_path case "$path" in /*) resolved_path="$path" ;; *) resolved_path="$(pwd)/$path" ;; esac while [ "$resolved_path" != "${resolved_path//\/\//\/}" ]; do resolved_path="${resolved_path//\/\//\/}" done case "$normalized_file" in "$resolved_path"*) matched=true break ;; esac done IFS="$old_ifs" if [ "$matched" = "false" ]; then bashunit::coverage::_diskcache_put "$cache_file" "$file" "0" return 1 fi bashunit::coverage::_diskcache_put "$cache_file" "$file" "1" local tracked_file="$_BASHUNIT_COVERAGE_TRACKED_FILES" if bashunit::parallel::is_enabled; then tracked_file="${_BASHUNIT_COVERAGE_TRACKED_FILES}.$$" fi if [ -d "$(dirname "$tracked_file")" ]; then if ! grep -q "^${normalized_file}$" "$tracked_file" 2>/dev/null; then echo "$normalized_file" >>"$tracked_file" fi fi return 0 } _BASHUNIT_COVERAGE_SAFE_NAME_OUT="" function bashunit::coverage::path_to_filename_to_slot() { local file="$1" local display_file="${file#"$PWD"/}" local safe_name="${display_file//\//_}" _BASHUNIT_COVERAGE_SAFE_NAME_OUT="${safe_name//./_}" } function bashunit::coverage::path_to_filename() { bashunit::coverage::path_to_filename_to_slot "$1" echo "$_BASHUNIT_COVERAGE_SAFE_NAME_OUT" } # src/coverage/rules_awk.sh _BASHUNIT_COVERAGE_AWK_RULES=' # Whether a source line counts as executable. Mirrors # bashunit::coverage::is_executable_line, quirk for quirk. function bu_is_executable(line, tmp, stripped, trimmed, first, rest, fn_rest, fn_name, cp_before, cp_after, i, c) { # Empty means "nothing but SPACES": the reference strips spaces only, so a # line of tabs is not empty and goes on to the rules below. tmp = line gsub(/ /, "", tmp) if (tmp == "") { return 0 } stripped = line sub(/^[ \t]+/, "", stripped) trimmed = stripped sub(/[ \t]+$/, "", trimmed) if (substr(trimmed, 1, 1) == "#") { return 0 } if (trimmed == "{" || trimmed == "}" || trimmed == "\\") { return 0 } # The first token ends at whitespace or at a `#`, so `done#note` still reads # as the keyword `done`. first = trimmed sub(/[ \t].*$/, "", first) sub(/#.*$/, "", first) if (first == "then" || first == "else" || first == "fi" || first == "do" || first == "done" || first == "esac" || first == "in" || first == ";;" || first == ";;&" || first == ";&" || first == ")") { rest = substr(trimmed, length(first) + 1) sub(/^[ \t]+/, "", rest) if (rest == "" || substr(rest, 1, 1) == "#") { return 0 } # A loop terminator still terminates the loop when a redirection or a pipe # follows: `done < file`, `done | sort`. if (first == "done") { return 0 } } # Function declarations: `[function ]name()` with an optional trailing `{`, # and no trailing comment. if (index(trimmed, "()") > 0) { fn_rest = trimmed if (fn_rest ~ /^function[ \t]/) { sub(/^function/, "", fn_rest) sub(/^[ \t]+/, "", fn_rest) } if (substr(fn_rest, length(fn_rest), 1) == "{") { fn_rest = substr(fn_rest, 1, length(fn_rest) - 1) sub(/[ \t]+$/, "", fn_rest) } if (length(fn_rest) >= 2 && substr(fn_rest, length(fn_rest) - 1) == "()") { fn_name = substr(fn_rest, 1, length(fn_rest) - 2) sub(/[ \t]+$/, "", fn_name) if (fn_name ~ /^[a-zA-Z_]/) { # Every character after the first must be a name character; the # reference accepts `:` so bashunit::fn() reads as a declaration. for (i = 2; i <= length(fn_name); i++) { c = substr(fn_name, i, 1) if (c !~ /[a-zA-Z0-9_:]/) { return 1 } } return 0 } } } # Case arms: something, then `)`, then end of line or a comment. The `)` only # closes an arm when no `(` opened earlier on the line, so `x=$(foo)`, # `((i++))` and `cmd <(sub)` stay statements (#1055). if (index(trimmed, ")") > 0) { cp_before = trimmed sub(/\).*$/, "", cp_before) if (cp_before != "" && index(cp_before, "(") == 0) { cp_after = substr(trimmed, length(cp_before) + 2) sub(/^[ \t]+/, "", cp_after) if (cp_after == "" || substr(cp_after, 1, 1) == "#") { return 0 } } } return 1 } # Whether a source line ends with a line continuation: an odd number of # trailing backslashes, and not a comment. Lives here because both the LCOV # emitter and the stats pass propagate hits along a continuation chain (#722). function bu_ends_with_continuation(line, lead, i, n) { lead = line sub(/^[ \t]+/, "", lead) if (substr(lead, 1, 1) == "#") { return 0 } n = 0 for (i = length(line); i >= 1; i--) { if (substr(line, i, 1) == "\\") { n++ } else { break } } return (n % 2) == 1 } ' _BASHUNIT_COVERAGE_AWK_LCOV=' # The guard is FILENAME, not the usual `FNR == NR`: a run with no recorded hits # passes an EMPTY first file, and `FNR == NR` is then true for the first record # of the SECOND file, which would swallow the source line 1. FILENAME == hitsfile { # The hits block: " ". hits[$1] = $2 next } { total++ src[total] = $0 } END { carry = 0 for (ln = 1; ln <= total; ln++) { h = (ln in hits) ? hits[ln] + 0 : 0 if (carry > 0 && h < carry) { h = carry; hits[ln] = h } if (h > 0 && bu_ends_with_continuation(src[ln])) { carry = h } else { carry = 0 } } executable = 0 hit = 0 for (ln = 1; ln <= total; ln++) { if (!bu_is_executable(src[ln])) { continue } executable++ h = (ln in hits) ? hits[ln] + 0 : 0 if (h > 0) { hit++ } printf "DA:%s,%s\n", ln, h } printf "LF:%s\n", executable printf "LH:%s\n", hit } ' _BASHUNIT_COVERAGE_AWK_STATS=' { hitsfile = $0 sub(/\t.*$/, "", hitsfile) src = $0 sub(/^[^\t]*\t/, "", src) split("", hits) if (hitsfile != "") { while ((getline hline < hitsfile) > 0) { split(hline, hp, " ") hits[hp[1] + 0] = hp[2] + 0 } close(hitsfile) } total = 0 split("", sl) while ((getline sline < src) > 0) { total++ sl[total] = sline } close(src) # The DEBUG trap attributes a multi-line statement to its starting line, so # the count carries forward across the backslash chain (#722). carry = 0 for (ln = 1; ln <= total; ln++) { h = (ln in hits) ? hits[ln] : 0 if (carry > 0 && h < carry) { h = carry; hits[ln] = h } if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 } } executable = 0 hit = 0 for (ln = 1; ln <= total; ln++) { if (!bu_is_executable(sl[ln])) { continue } executable++ if ((ln in hits) && hits[ln] > 0) { hit++ } } print executable "\t" hit "\t" src } ' _BASHUNIT_COVERAGE_AWK_LCOV_ALL=' # An arm ran as often as its FIRST executable line did (#1061). function bu_arm_taken(s, e, ln) { for (ln = s; ln <= e; ln++) { if (!bu_is_executable(sl[ln])) { continue } return (ln in hits) ? hits[ln] : 0 } return 0 } BEGIN { print "TN:" } { hitsfile = $0 sub(/\t.*$/, "", hitsfile) src = $0 sub(/^[^\t]*\t/, "", src) split("", hits) if (hitsfile != "") { while ((getline hline < hitsfile) > 0) { split(hline, hp, " ") hits[hp[1] + 0] = hp[2] + 0 } close(hitsfile) } total = 0 split("", sl) while ((getline sline < src) > 0) { total++ sl[total] = sline } close(src) # The DEBUG trap attributes a multi-line statement to its starting line, so # the count carries forward across the backslash chain (#722). carry = 0 for (ln = 1; ln <= total; ln++) { h = (ln in hits) ? hits[ln] : 0 if (carry > 0 && h < carry) { h = carry; hits[ln] = h } if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 } } bu_fn_reset() bu_br_reset() for (ln = 1; ln <= total; ln++) { bu_fn_line(sl[ln], ln) bu_br_line(sl[ln], ln) } bu_fn_finish(total) print "SF:" src # FN lines as we walk, the matching FNDA lines after them, per LCOV # convention. fn_hit = 0 fnda = "" for (i = 1; i <= fn_count; i++) { print "FN:" fns[i] "," fnn[i] any = 0 for (ln = fns[i]; ln <= fne[i]; ln++) { if ((ln in hits) && hits[ln] > 0) { any = 1; break } } fnda = fnda "FNDA:" any "," fnn[i] "\n" if (any == 1) { fn_hit++ } } printf "%s", fnda print "FNF:" fn_count print "FNH:" fn_hit br_total = 0 br_hit = 0 for (i = 1; i <= br_count; i++) { n = split(br_arms[i], arms, ",") for (a = 1; a <= n; a++) { split(arms[a], se, ":") taken = bu_arm_taken(se[1] + 0, se[2] + 0) print "BRDA:" br_dec[i] "," (i - 1) "," (a - 1) "," taken br_total++ if (taken > 0) { br_hit++ } } } print "BRF:" br_total print "BRH:" br_hit executable = 0 hit = 0 for (ln = 1; ln <= total; ln++) { if (!bu_is_executable(sl[ln])) { continue } executable++ h = (ln in hits) ? hits[ln] : 0 if (h > 0) { hit++ } print "DA:" ln "," h } print "LF:" executable print "LH:" hit print "end_of_record" } ' function bashunit::coverage::awk_rules() { printf '%s' "$_BASHUNIT_COVERAGE_AWK_RULES" } function bashunit::coverage::awk_lcov_lines() { local file="$1" bashunit::coverage::ensure_hits_aggregated bashunit::coverage::hits_file_for "$file" local hits_file="$_BASHUNIT_COVERAGE_HITS_FILE_OUT" if [ -z "$hits_file" ] || [ ! -f "$hits_file" ]; then hits_file="/dev/null" fi env LC_ALL=C "$AWK" -v hitsfile="$hits_file" \ "${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_LCOV}" \ "$hits_file" "$file" } function bashunit::coverage::awk_file_stats() { env LC_ALL=C "$AWK" \ "${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_STATS}" \ "$1" } function bashunit::coverage::awk_lcov_report() { env LC_ALL=C "$AWK" \ "${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_FUNCTIONS}\ ${_BASHUNIT_COVERAGE_AWK_BRANCHES}${_BASHUNIT_COVERAGE_AWK_LCOV_ALL}" \ "$1" } # src/coverage/lines.sh function bashunit::coverage::is_executable_line() { local line="$1" local lineno="$2" : "$lineno" [ -z "${line// /}" ] && return 1 local stripped="${line#"${line%%[![:space:]]*}"}" local _trail="${stripped##*[![:space:]]}" local trimmed="${stripped%"$_trail"}" case "$trimmed" in '#'*) return 1 ;; '{' | '}' | [\\]) return 1 ;; esac local first="${trimmed%%[[:space:]]*}" first="${first%%'#'*}" 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 if [ "$first" = 'done' ]; then return 1 fi ;; esac case "$trimmed" in *'()'*) local fn_rest="$trimmed" case "$fn_rest" in 'function'[[:space:]]*) fn_rest="${fn_rest#function}" fn_rest="${fn_rest#"${fn_rest%%[![:space:]]*}"}" ;; esac case "$fn_rest" in *'{') fn_rest="${fn_rest%'{'}" fn_rest="${fn_rest%"${fn_rest##*[![:space:]]}"}" ;; esac case "$fn_rest" in *'()') local fn_name="${fn_rest%'()'}" fn_name="${fn_name%"${fn_name##*[![:space:]]}"}" case "$fn_name" in [a-zA-Z_]*) case "${fn_name#?}" in *[!a-zA-Z0-9_:]*) : ;; *) return 1 ;; esac ;; esac ;; esac ;; esac case "$line" in *')'*) local cp_before="${line%%')'*}" case "$cp_before" in '' | *'('*) : ;; *) local cp_after="${line#*')'}" cp_after="${cp_after#"${cp_after%%[![:space:]]*}"}" case "$cp_after" in '' | '#'*) return 1 ;; esac ;; esac ;; esac 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 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 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::compute_file_coverage() { local file="$1" bashunit::coverage::load_hits_by_line "$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=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0} [ "$line_hits" -gt 0 ] && ((++hit)) done echo "${executable}:${hit}" } 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 ] } _BASHUNIT_COVERAGE_HITS_AGGREGATED=false _BASHUNIT_COVERAGE_HITS_FILE_OUT="" function bashunit::coverage::hits_file_for() { _BASHUNIT_COVERAGE_HITS_FILE_OUT="" [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] || return 0 local dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}/hits" local name="${1//[^a-zA-Z0-9]/_}" _BASHUNIT_COVERAGE_HITS_FILE_OUT="$dir/$name" } _BASHUNIT_COVERAGE_MANIFEST_OUT="" function bashunit::coverage::write_batch_manifest() { _BASHUNIT_COVERAGE_MANIFEST_OUT="" local data_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}" { [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] && [ -d "$data_dir" ]; } || return 1 bashunit::coverage::ensure_hits_aggregated local manifest="$data_dir/$1" local tracked=0 file { while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue tracked=$((tracked + 1)) bashunit::coverage::hits_file_for "$file" printf '%s\t%s\n' "$_BASHUNIT_COVERAGE_HITS_FILE_OUT" "$file" done < <(bashunit::coverage::get_tracked_files) } >"$manifest" 2>/dev/null || return 1 if [ "$tracked" -gt 0 ]; then _BASHUNIT_COVERAGE_MANIFEST_OUT="$manifest" fi return 0 } function bashunit::coverage::invalidate_hits_aggregation() { _BASHUNIT_COVERAGE_HITS_AGGREGATED=false _BASHUNIT_COVERAGE_HITS_BY_LINE_FILE="" } function bashunit::coverage::ensure_hits_aggregated() { if [ "$_BASHUNIT_COVERAGE_HITS_AGGREGATED" = true ]; then return 0 fi _BASHUNIT_COVERAGE_HITS_AGGREGATED=true [ -n "${_BASHUNIT_COVERAGE_DATA_FILE:-}" ] || return 0 [ -f "$_BASHUNIT_COVERAGE_DATA_FILE" ] || return 0 local dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}/hits" rm -rf "$dir" 2>/dev/null || true mkdir -p "$dir" 2>/dev/null || return 0 env LC_ALL=C awk -v dir="$dir" ' { i = length($0) while (i > 0 && substr($0, i, 1) != ":") { i-- } if (i == 0) { next } path = substr($0, 1, i - 1) line = substr($0, i + 1) if (line !~ /^[0-9]+$/) { next } key = path SUBSEP line if (!(key in counts)) { order[++n] = key } counts[key]++ } END { for (j = 1; j <= n; j++) { split(order[j], parts, SUBSEP) name = parts[1] gsub(/[^a-zA-Z0-9]/, "_", name) print parts[2], counts[order[j]] > (dir "/" name) } for (j = 1; j <= n; j++) { split(order[j], parts, SUBSEP) name = parts[1] gsub(/[^a-zA-Z0-9]/, "_", name) close(dir "/" name) } } ' "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null || true } function bashunit::coverage::get_all_line_hits() { local file="$1" if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then return fi bashunit::coverage::ensure_hits_aggregated local -a counts=() local count lineno maxln=0 local hits_file bashunit::coverage::hits_file_for "$file" hits_file=$_BASHUNIT_COVERAGE_HITS_FILE_OUT if [ -n "$hits_file" ] && [ -f "$hits_file" ]; then while read -r lineno count; do if [ -n "$count" ]; then counts[lineno]=$count [ "$lineno" -gt "$maxln" ] && maxln=$lineno fi done <"$hits_file" fi if [ "$maxln" -eq 0 ]; then return fi 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 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 return 0 } declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE _BASHUNIT_COVERAGE_HITS_BY_LINE_FILE="" function bashunit::coverage::load_hits_by_line() { local file="$1" if [ -n "$file" ] && [ "$file" = "$_BASHUNIT_COVERAGE_HITS_BY_LINE_FILE" ]; then return 0 fi bashunit::coverage::ensure_hits_aggregated _BASHUNIT_COVERAGE_HITS_BY_LINE=() _BASHUNIT_COVERAGE_HITS_BY_LINE_FILE="$file" local hl_lineno hl_count while IFS=: read -r hl_lineno hl_count; do [ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count done < <(bashunit::coverage::get_all_line_hits "$file") } function bashunit::coverage::get_all_line_tests() { local file="$1" if [ ! -f "${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" ]; then return fi grep "^${file}:" "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 2>/dev/null | sed "s|^${file}:||" | sort -u } # src/coverage/functions.sh _BASHUNIT_COVERAGE_AWK_FUNCTIONS=' BEGIN { SQ = sprintf("%c", 39) } # Scans one line under the quote and heredoc state carried over from the lines # before it -- a string or a heredoc body can span lines, so per-line state is # not enough. Sets nopen/nclose to the braces that are code, and code_start to # 1 when the line begins outside any string or heredoc, which is the only place # a declaration can start. function bu_scan(line, i, n, c, rest, delim, q) { nopen = 0 nclose = 0 code_start = (in_s == 0 && in_d == 0 && hd == "") if (hd != "") { rest = line if (hd_strip) { sub(/^\t+/, "", rest) } if (rest == hd) { hd = "" } return } n = length(line) for (i = 1; i <= n; i++) { c = substr(line, i, 1) if (in_s) { # Single quotes take no escapes: the next one always closes. if (c == SQ) { in_s = 0 } continue } if (in_d) { if (c == "\\") { i++; continue } if (c == "\"") { in_d = 0 } continue } if (c == "\\") { i++; continue } if (c == SQ) { in_s = 1; continue } if (c == "\"") { in_d = 1; continue } # A `#` opens a comment only where bash opens one, at the start of a word, # so ${x#foo} and a#b keep their braces. if (c == "#") { if (i == 1) { return } q = substr(line, i - 1, 1) if (q == " " || q == "\t" || q == ";" || q == "&" || q == "|" || q == "(") { return } continue } if (c == "<" && substr(line, i + 1, 1) == "<") { # `<<<` is a here-string: one line, no body. Consume all three so the # second `<` cannot read as the start of a heredoc and swallow the file. if (substr(line, i + 2, 1) == "<") { i = i + 2; continue } rest = substr(line, i + 2) hd_strip = 0 if (substr(rest, 1, 1) == "-") { hd_strip = 1; rest = substr(rest, 2) } sub(/^[ \t]+/, "", rest) q = substr(rest, 1, 1) if (q == SQ || q == "\"") { delim = substr(rest, 2) if (index(delim, q) == 0) { delim = "" } else { sub(q ".*$", "", delim) } } else { delim = rest sub(/[ \t;)&|<>].*$/, "", delim) } # The body starts on the next line, so nothing after the operator on this # one can close the function. if (delim != "") { hd = delim; return } continue } if (c == "{") { nopen++ } else if (c == "}") { nclose++ } } } # Feeds one line to the scanner, recording a declaration when it opens and its # span when the braces balance. A caller reads the records out of fnn/fns/fne, # which is what lets the per-file API below and the batch report share this. function bu_fn_line(line, lineno, stripped, name, after, ok) { bu_scan(line) if (in_function == 0 && code_start) { # Pattern 1: function name() { or function name { # Pattern 2: name() { or name () { stripped = line sub(/^[ \t]+/, "", stripped) if (stripped ~ /^function[ \t]/) { sub(/^function/, "", stripped) sub(/^[ \t]+/, "", stripped) } # The candidate name is the first word, ending at whitespace, `(` or `{`. name = stripped sub(/[ \t({].*$/, "", name) if (name != "") { ok = 1 # A candidate holding anything outside the identifier alphabet is not a # function name. Cutting at the first `{` means `VAR="x${Y}"` yields # `VAR="x$`, whose trailing `{Y}"` then looks like a body opener -- every # such assignment became a phantom FN record, and one containing the # record separator `|` shifted the fields and crashed the arithmetic in # report_lcov (#936). Checking only the first character let all of that # through. if (name ~ /[^a-zA-Z0-9_:]/) { ok = 0 } else if (name !~ /^[a-zA-Z_]/) { ok = 0 } else { # A declaration continues with `()` or `{`; a call does not. after = substr(stripped, length(name) + 1) sub(/^[ \t]+/, "", after) if (substr(after, 1, 2) != "()" && substr(after, 1, 1) != "{") { ok = 0 } } if (ok) { in_function = 1 current_fn = name fn_start = lineno brace_count = nopen - nclose # Single-line function: braces balance on the same line, both present. if (brace_count == 0 && nopen > 0 && nclose > 0) { bu_fn_emit(lineno) } return } } } if (in_function == 1) { brace_count = brace_count + nopen - nclose if (brace_count <= 0) { bu_fn_emit(lineno) } } } function bu_fn_emit(endline) { fn_count++ fnn[fn_count] = current_fn fns[fn_count] = fn_start fne[fn_count] = endline in_function = 0 current_fn = "" brace_count = 0 } # An unclosed function (should not happen in valid code) still gets a record, # ending at the last line, so a truncated file cannot drop one silently. function bu_fn_finish(lastline) { if (in_function == 1 && current_fn != "") { bu_fn_emit(lastline) } } # Clears the per-file state: quote and heredoc carry-over, the open # declaration, and the records collected so far. function bu_fn_reset() { in_s = 0; in_d = 0; hd = ""; hd_strip = 0 in_function = 0; current_fn = ""; brace_count = 0 fn_count = 0 } ' _BASHUNIT_COVERAGE_AWK_FUNCTIONS_MAIN=' { bu_fn_line($0, NR) } END { bu_fn_finish(NR) for (bu_i = 1; bu_i <= fn_count; bu_i++) { print fnn[bu_i] "|" fns[bu_i] "|" fne[bu_i] } } ' function bashunit::coverage::extract_functions() { env LC_ALL=C "$AWK" \ "${_BASHUNIT_COVERAGE_AWK_FUNCTIONS}${_BASHUNIT_COVERAGE_AWK_FUNCTIONS_MAIN}" "$1" } # src/coverage/engine.sh _BASHUNIT_COVERAGE_XTRACE_FS=$'\034' _BASHUNIT_COVERAGE_XTRACE_PS4='@|${BASH_SOURCE}'"$_BASHUNIT_COVERAGE_XTRACE_FS" _BASHUNIT_COVERAGE_XTRACE_PS4="$_BASHUNIT_COVERAGE_XTRACE_PS4"'${LINENO}'"$_BASHUNIT_COVERAGE_XTRACE_FS"' ' _BASHUNIT_COVERAGE_XTRACE_FD="" _BASHUNIT_COVERAGE_XTRACE_FILE="" _BASHUNIT_COVERAGE_XTRACE_SAVED_PS4="" _BASHUNIT_COVERAGE_DATA_TARGET_OUT="" _BASHUNIT_COVERAGE_HITS_TARGET_OUT="" function bashunit::coverage::enable_trap() { if ! bashunit::env::is_coverage_enabled; then return 0 fi local engine="${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" if [ -z "$engine" ]; then engine=$(bashunit::coverage::resolve_engine) fi if [ "$engine" = "xtrace" ]; then bashunit::coverage::_enable_xtrace return 0 fi set -T local record='bashunit::coverage::record_line "${BASH_SOURCE[0]:-}" "${LINENO:-}"' if [ -n "${_BASHUNIT_COVERAGE_TRAP_GLOB:-}" ]; then local guarded='case "${BASH_SOURCE[0]:-}" in ' guarded="$guarded${_BASHUNIT_COVERAGE_TRAP_GLOB}) $record ;; esac" trap "$guarded" DEBUG else trap "$record" DEBUG fi } function bashunit::coverage::disable_trap() { if [ -n "$_BASHUNIT_COVERAGE_XTRACE_FD" ]; then bashunit::coverage::_disable_xtrace return 0 fi trap - DEBUG set +T } function bashunit::coverage::_enable_xtrace() { local coverage_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}" _BASHUNIT_COVERAGE_XTRACE_FILE="${coverage_dir}/xtrace.$$.${_BASHUNIT_RUNNER_RESULT_ORDINAL:-0}.trace" exec {_BASHUNIT_COVERAGE_XTRACE_FD}>>"$_BASHUNIT_COVERAGE_XTRACE_FILE" local test_ctx="" if [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" ] && [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]; then test_ctx="${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}" fi builtin printf '%sTEST%s%s\n' "$_BASHUNIT_COVERAGE_XTRACE_FS" \ "$_BASHUNIT_COVERAGE_XTRACE_FS" "$test_ctx" >&"$_BASHUNIT_COVERAGE_XTRACE_FD" _BASHUNIT_COVERAGE_XTRACE_SAVED_PS4="${PS4:-}" BASH_XTRACEFD=$_BASHUNIT_COVERAGE_XTRACE_FD PS4=$_BASHUNIT_COVERAGE_XTRACE_PS4 set -x } function bashunit::coverage::_disable_xtrace() { set +x PS4=$_BASHUNIT_COVERAGE_XTRACE_SAVED_PS4 local fd="$_BASHUNIT_COVERAGE_XTRACE_FD" _BASHUNIT_COVERAGE_XTRACE_FD="" _BASHUNIT_COVERAGE_XTRACE_FILE="" unset BASH_XTRACEFD exec {fd}>&- } _BASHUNIT_COVERAGE_XTRACE_PATHS_AWK=' { if (substr($0, 1, 1) != "@") { next } i = 1 while (substr($0, i, 1) == "@") { i++ } if (substr($0, i, 1) != "|") { next } rest = substr($0, i + 1) p = index(rest, fsc) if (p == 0) { next } path = substr(rest, 1, p - 1) if (path != "" && !(path in seen)) { seen[path] = 1; print path } } ' _BASHUNIT_COVERAGE_XTRACE_EMIT_AWK=' FILENAME == map_file { tab = index($0, "\t") if (tab > 0) { norm = substr($0, tab + 1) if (norm != "-") { tracked[substr($0, 1, tab - 1)] = norm } } next } substr($0, 1, 1) == fsc { head = substr($0, 2) if (substr(head, 1, 5) == "TEST" fsc) { ctx = substr(head, 6); next } } { if (substr($0, 1, 1) != "@") { next } i = 1 while (substr($0, i, 1) == "@") { i++ } if (substr($0, i, 1) != "|") { next } rest = substr($0, i + 1) p = index(rest, fsc) if (p == 0) { next } path = substr(rest, 1, p - 1) if (!(path in tracked)) { next } tail = substr(rest, p + 1) q = index(tail, fsc) if (q == 0) { next } line = substr(tail, 1, q - 1) if (line == "") { next } record = tracked[path] ":" line print record >> data_out if (ctx != "") { print record "|" ctx >> hits_out } } ' function bashunit::coverage::finalize() { bashunit::coverage::invalidate_hits_aggregation if [ "${_BASHUNIT_COVERAGE_ENGINE_RESOLVED:-}" != "xtrace" ]; then return 0 fi [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] || return 0 local coverage_dir="${_BASHUNIT_COVERAGE_DATA_FILE%/*}" local -a traces=() local trace for trace in "$coverage_dir"/xtrace.*.trace; do [ -f "$trace" ] || continue traces[${#traces[@]}]="$trace" done [ "${#traces[@]}" -gt 0 ] || return 0 local map_file="${coverage_dir}/xtrace-map.dat" local paths_file="${coverage_dir}/xtrace-paths.dat" : >"$map_file" "$AWK" -v fsc="$_BASHUNIT_COVERAGE_XTRACE_FS" \ "$_BASHUNIT_COVERAGE_XTRACE_PATHS_AWK" "${traces[@]}" >"$paths_file" local path while IFS= read -r path; do [ -n "$path" ] || continue if bashunit::coverage::should_track "$path"; then builtin printf '%s\t%s\n' "$path" \ "$(bashunit::coverage::normalize_path "$path")" >>"$map_file" else builtin printf '%s\t-\n' "$path" >>"$map_file" fi done <"$paths_file" "$AWK" \ -v map_file="$map_file" \ -v fsc="$_BASHUNIT_COVERAGE_XTRACE_FS" \ -v data_out="$_BASHUNIT_COVERAGE_DATA_FILE" \ -v hits_out="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" \ "$_BASHUNIT_COVERAGE_XTRACE_EMIT_AWK" "$map_file" "${traces[@]}" rm -f "$paths_file" "${traces[@]}" } function bashunit::coverage::record_line() { local file="$1" local lineno="$2" { [ -z "$file" ] || [ -z "$lineno" ]; } && return 0 [ -z "$_BASHUNIT_COVERAGE_DATA_FILE" ] && return 0 local entry="" decision="" normalized_file="" if bashunit::coverage::lookup_get "_BASHUNIT_COVLOOKUP_FILE_" "$file"; then entry="$_BASHUNIT_COVERAGE_LOOKUP_OUT" decision="${entry%% *}" [ "$decision" = "0" ] && return 0 normalized_file="${entry#* }" else if bashunit::coverage::should_track "$file"; then decision=1 normalized_file=$(bashunit::coverage::normalize_path "$file") bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_FILE_" "$file" \ "1 $normalized_file" else bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_FILE_" "$file" "0" return 0 fi fi bashunit::coverage::_resolve_output_files builtin printf '%s:%s\n' "$normalized_file" "$lineno" \ >>"$_BASHUNIT_COVERAGE_DATA_TARGET_OUT" if [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" ] && [ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]; then builtin printf '%s:%s|%s:%s\n' "$normalized_file" "$lineno" \ "$_BASHUNIT_COVERAGE_CURRENT_TEST_FILE" "$_BASHUNIT_COVERAGE_CURRENT_TEST_FN" \ >>"$_BASHUNIT_COVERAGE_HITS_TARGET_OUT" fi _BASHUNIT_COVERAGE_HITS_AGGREGATED=false _BASHUNIT_COVERAGE_HITS_BY_LINE_FILE="" } function bashunit::coverage::_resolve_output_files() { 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 _BASHUNIT_COVERAGE_DATA_TARGET_OUT="${_BASHUNIT_COVERAGE_DATA_FILE}.$$" _BASHUNIT_COVERAGE_HITS_TARGET_OUT="${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.$$" else _BASHUNIT_COVERAGE_DATA_TARGET_OUT="$_BASHUNIT_COVERAGE_DATA_FILE" _BASHUNIT_COVERAGE_HITS_TARGET_OUT="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" fi } function bashunit::coverage::flush_buffer() { bashunit::coverage::invalidate_hits_aggregation } function bashunit::coverage::aggregate_parallel() { bashunit::coverage::invalidate_hits_aggregation local base_file="$_BASHUNIT_COVERAGE_DATA_FILE" local tracked_base="$_BASHUNIT_COVERAGE_TRACKED_FILES" local test_hits_base="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 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 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 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 if [ -f "$tracked_base" ]; then sort -u "$tracked_base" -o "$tracked_base" fi } 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 } # src/coverage/stats.sh _BASHUNIT_COVERAGE_CLASS_OUT="" _BASHUNIT_COVERAGE_COLOR_OUT="" _BASHUNIT_COVERAGE_TOTAL_EXEC_OUT=0 _BASHUNIT_COVERAGE_TOTAL_HIT_OUT=0 function bashunit::coverage::class_to_slot() { local pct="$1" if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then _BASHUNIT_COVERAGE_CLASS_OUT="high" elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then _BASHUNIT_COVERAGE_CLASS_OUT="medium" else _BASHUNIT_COVERAGE_CLASS_OUT="low" fi } function bashunit::coverage::color_to_slot() { case "$1" in high) _BASHUNIT_COVERAGE_COLOR_OUT="$_BASHUNIT_COLOR_PASSED" ;; medium) _BASHUNIT_COVERAGE_COLOR_OUT="$_BASHUNIT_COLOR_SKIPPED" ;; low) _BASHUNIT_COVERAGE_COLOR_OUT="$_BASHUNIT_COLOR_FAILED" ;; *) _BASHUNIT_COVERAGE_COLOR_OUT="" ;; esac } function bashunit::coverage::get_coverage_class() { bashunit::coverage::class_to_slot "$1" echo "$_BASHUNIT_COVERAGE_CLASS_OUT" } function bashunit::coverage::get_color_for_class() { bashunit::coverage::color_to_slot "$1" printf '%s' "$_BASHUNIT_COVERAGE_COLOR_OUT" } function bashunit::coverage::calculate_percentage() { local hit="$1" local executable="$2" if [ "$executable" -gt 0 ]; then echo $((hit * 100 / executable)) else echo "0" fi } _BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="" _BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="" _BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="" _BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="" function bashunit::coverage::_compute_file_stats() { local file="$1" local stats stats=$(bashunit::coverage::compute_file_coverage "$file") bashunit::coverage::_derive_file_stats "${stats%%:*}" "${stats##*:}" } function bashunit::coverage::_derive_file_stats() { local executable="$1" hit="$2" _BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT="$executable" _BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT="$hit" local pct=0 if [ "$executable" -gt 0 ]; then pct=$((hit * 100 / executable)) fi _BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT="$pct" bashunit::coverage::class_to_slot "$pct" _BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT="$_BASHUNIT_COVERAGE_CLASS_OUT" } function bashunit::coverage::get_file_stats() { bashunit::coverage::_compute_file_stats "$1" echo "${_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT}:${_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT}:\ ${_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT}:${_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT}" } _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 function bashunit::coverage::precompute_file_stats() { bashunit::coverage::seed_tracked_files _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::reset_lookup_namespace "_BASHUNIT_COVLOOKUP_STATS_" if bashunit::coverage::_precompute_batch; then return 0 fi local file while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue bashunit::coverage::_compute_file_stats "$file" bashunit::coverage::_record_file_stats "$file" \ "$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" "$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" done < <(bashunit::coverage::get_tracked_files) } function bashunit::coverage::_record_file_stats() { local file="$1" bashunit::coverage::_derive_file_stats "$2" "$3" local idx="$_BASHUNIT_COVERAGE_STATS_COUNT" _BASHUNIT_COVERAGE_STATS_FILES[idx]="$file" _BASHUNIT_COVERAGE_STATS_EXEC[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" _BASHUNIT_COVERAGE_STATS_HIT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" _BASHUNIT_COVERAGE_STATS_PCT[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT" _BASHUNIT_COVERAGE_STATS_CLASS[idx]="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT" _BASHUNIT_COVERAGE_STATS_COUNT=$((idx + 1)) bashunit::coverage::lookup_put "_BASHUNIT_COVLOOKUP_STATS_" "$file" "$idx" } function bashunit::coverage::_precompute_batch() { bashunit::coverage::write_batch_manifest "stats-manifest" || return 1 local manifest="$_BASHUNIT_COVERAGE_MANIFEST_OUT" if [ -z "$manifest" ]; then return 0 fi local executable hit file while IFS="$(printf '\t')" read -r executable hit file; do [ -n "$file" ] || continue bashunit::coverage::_record_file_stats "$file" "$executable" "$hit" done < <(bashunit::coverage::awk_file_stats "$manifest" 2>/dev/null) [ "$_BASHUNIT_COVERAGE_STATS_COUNT" -gt 0 ] } function bashunit::coverage::cached_stats_to_slots() { local file="$1" if bashunit::coverage::lookup_get "_BASHUNIT_COVLOOKUP_STATS_" "$file"; then local idx="$_BASHUNIT_COVERAGE_LOOKUP_OUT" _BASHUNIT_COVERAGE_SPLIT_EXEC_OUT="${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}" _BASHUNIT_COVERAGE_SPLIT_HIT_OUT="${_BASHUNIT_COVERAGE_STATS_HIT[idx]}" _BASHUNIT_COVERAGE_SPLIT_PCT_OUT="${_BASHUNIT_COVERAGE_STATS_PCT[idx]}" _BASHUNIT_COVERAGE_SPLIT_CLASS_OUT="${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}" return 0 fi bashunit::coverage::_compute_file_stats "$file" _BASHUNIT_COVERAGE_SPLIT_EXEC_OUT="$_BASHUNIT_COVERAGE_FILE_STATS_EXEC_OUT" _BASHUNIT_COVERAGE_SPLIT_HIT_OUT="$_BASHUNIT_COVERAGE_FILE_STATS_HIT_OUT" _BASHUNIT_COVERAGE_SPLIT_PCT_OUT="$_BASHUNIT_COVERAGE_FILE_STATS_PCT_OUT" _BASHUNIT_COVERAGE_SPLIT_CLASS_OUT="$_BASHUNIT_COVERAGE_FILE_STATS_CLASS_OUT" } function bashunit::coverage::get_cached_stats() { local file="$1" if bashunit::coverage::lookup_get "_BASHUNIT_COVLOOKUP_STATS_" "$file"; then local idx="$_BASHUNIT_COVERAGE_LOOKUP_OUT" echo "${_BASHUNIT_COVERAGE_STATS_EXEC[idx]}:${_BASHUNIT_COVERAGE_STATS_HIT[idx]}\ :${_BASHUNIT_COVERAGE_STATS_PCT[idx]}:${_BASHUNIT_COVERAGE_STATS_CLASS[idx]}" return 0 fi bashunit::coverage::get_file_stats "$file" } _BASHUNIT_COVERAGE_SPLIT_EXEC_OUT="" _BASHUNIT_COVERAGE_SPLIT_HIT_OUT="" _BASHUNIT_COVERAGE_SPLIT_PCT_OUT="" _BASHUNIT_COVERAGE_SPLIT_CLASS_OUT="" function bashunit::coverage::split_stats() { local stats="$1" rest _BASHUNIT_COVERAGE_SPLIT_EXEC_OUT="${stats%%:*}" rest="${stats#*:}" _BASHUNIT_COVERAGE_SPLIT_HIT_OUT="${rest%%:*}" rest="${rest#*:}" _BASHUNIT_COVERAGE_SPLIT_PCT_OUT="${rest%%:*}" _BASHUNIT_COVERAGE_SPLIT_CLASS_OUT="${rest#*:}" } function bashunit::coverage::totals_to_slots() { 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_TOTAL_EXEC_OUT=$total_executable _BASHUNIT_COVERAGE_TOTAL_HIT_OUT=$total_hit } function bashunit::coverage::get_percentage() { bashunit::coverage::totals_to_slots bashunit::coverage::calculate_percentage \ "$_BASHUNIT_COVERAGE_TOTAL_HIT_OUT" "$_BASHUNIT_COVERAGE_TOTAL_EXEC_OUT" } function bashunit::coverage::check_threshold() { if [ -z "$BASHUNIT_COVERAGE_MIN" ]; then return 0 fi local pct if bashunit::coverage::is_diff_enabled && [ -n "$_BASHUNIT_COVERAGE_DIFF_PCT_OUT" ]; then pct="$_BASHUNIT_COVERAGE_DIFF_PCT_OUT" else bashunit::coverage::totals_to_slots pct=$(bashunit::coverage::calculate_percentage \ "$_BASHUNIT_COVERAGE_TOTAL_HIT_OUT" "$_BASHUNIT_COVERAGE_TOTAL_EXEC_OUT") fi if [ "$pct" -lt "$BASHUNIT_COVERAGE_MIN" ]; then local message if [ "${_BASHUNIT_COVERAGE_TOTAL_EXEC_OUT:-0}" -eq 0 ]; then message=$(printf "%sCoverage gate failed: no executable lines were tracked%s\n%s" \ "$_BASHUNIT_COLOR_FAILED" "$_BASHUNIT_COLOR_DEFAULT" \ "Check --coverage-paths (BASHUNIT_COVERAGE_PATHS): it matched no shell file with executable code.") else message=$(printf "%sCoverage %d%% is below minimum %d%%%s" \ "$_BASHUNIT_COLOR_FAILED" "$pct" "$BASHUNIT_COVERAGE_MIN" "$_BASHUNIT_COLOR_DEFAULT") fi if bashunit::env::is_machine_output_enabled; then printf "%s\n" "$message" >&2 else printf "%s\n" "$message" fi return 1 fi return 0 } # src/coverage/branches.sh _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 } 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 } 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::_branch_push_loop() { local lineno=$1 loop_decision_line[loop_depth]=$lineno loop_arm_start[loop_depth]=$((lineno + 1)) loop_depth=$((loop_depth + 1)) } function bashunit::coverage::_branch_emit_loop() { local lineno=$1 idx=$((loop_depth - 1)) echo "${loop_decision_line[$idx]}|loop|${loop_arm_start[$idx]}:$((lineno - 1))" loop_depth=$idx } _BASHUNIT_COVERAGE_AWK_BRANCHES=' function bu_br_append_arm(existing, s, e) { return (existing == "") ? (s ":" e) : (existing "," s ":" e) } # A case-pattern opener ends with `)`, optionally followed by a comment. This # does not exclude a `(` earlier on the line -- the reference does not either. function bu_br_is_case_pattern(t, before, after) { if (index(t, ")") == 0) { return 0 } before = t sub(/\).*$/, "", before) after = substr(t, length(before) + 2) sub(/^[ \t]+/, "", after) return (after == "" || substr(after, 1, 1) == "#") } function bu_br_add(decision, kind, arms) { br_count++ br_dec[br_count] = decision br_kind[br_count] = kind br_arms[br_count] = arms } function bu_br_line(line, lineno, trimmed, first, idx) { trimmed = line sub(/^[ \t]+/, "", trimmed) if (trimmed == "" || substr(trimmed, 1, 1) == "#") { return } first = trimmed sub(/[ \t;].*$/, "", first) if (first == "if") { if_decision_line[if_depth] = lineno if_arms[if_depth] = "" if_arm_start[if_depth] = lineno + 1 if_depth++ } else if (first == "elif" || first == "else") { if (if_depth > 0) { idx = if_depth - 1 if_arms[idx] = bu_br_append_arm(if_arms[idx], if_arm_start[idx], lineno - 1) if_arm_start[idx] = lineno + 1 } } else if (first == "fi") { if (if_depth > 0) { idx = if_depth - 1 bu_br_add(if_decision_line[idx], "if", bu_br_append_arm(if_arms[idx], if_arm_start[idx], lineno - 1)) if_depth = idx } } else if (first == "case") { case_decision_line[case_depth] = lineno case_arms[case_depth] = "" case_arm_start[case_depth] = 0 case_in_pattern[case_depth] = 0 case_depth++ } else if (first == "esac") { if (case_depth > 0) { idx = case_depth - 1 if (case_in_pattern[idx] == 1) { case_arms[idx] = bu_br_append_arm(case_arms[idx], case_arm_start[idx], lineno - 1) case_in_pattern[idx] = 0 } if (case_arms[idx] != "") { bu_br_add(case_decision_line[idx], "case", case_arms[idx]) } case_depth = idx } } else if (first == "while" || first == "until" || first == "for" || first == "select") { loop_decision_line[loop_depth] = lineno loop_arm_start[loop_depth] = lineno + 1 loop_depth++ } else if (first == "done") { if (loop_depth > 0) { idx = loop_depth - 1 bu_br_add(loop_decision_line[idx], "loop", loop_arm_start[idx] ":" (lineno - 1)) loop_depth = idx } } else if (case_depth > 0) { idx = case_depth - 1 if (trimmed ~ /^;;&/ || trimmed ~ /^;;/ || trimmed ~ /^;&/) { if (case_in_pattern[idx] == 1) { case_arms[idx] = bu_br_append_arm(case_arms[idx], case_arm_start[idx], lineno - 1) case_in_pattern[idx] = 0 } } else if (bu_br_is_case_pattern(trimmed)) { case_arm_start[idx] = lineno + 1 case_in_pattern[idx] = 1 } } } # The depths must be numeric before they index anything: an uninitialised awk # variable is the empty string as a subscript, so the first push would land in # arr[""] and the matching pop would read arr[0]. function bu_br_reset() { if_depth = 0 case_depth = 0 loop_depth = 0 br_count = 0 } ' 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 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 -a loop_decision_line=() loop_arm_start=() local loop_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:]\;]*}" 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" ;; 'while' | 'until' | 'for' | 'select') bashunit::coverage::_branch_push_loop "$lineno" ;; 'done') [ "$loop_depth" -gt 0 ] && bashunit::coverage::_branch_emit_loop "$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 } _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 _BASHUNIT_ARM_TAKEN_OUT="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" return done _BASHUNIT_ARM_TAKEN_OUT=0 } function bashunit::coverage::compute_branch_hits() { local file="$1" bashunit::coverage::load_hits_by_line "$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") } # src/coverage/report_text.sh function bashunit::coverage::print_engine_notice() { if bashunit::coverage::engine_was_downgraded; then printf "%sWarning: coverage engine 'xtrace' needs Bash 4.1+ (running %s.%s); using 'trap'.%s\n" \ "$_BASHUNIT_COLOR_INCOMPLETE" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}" \ "$_BASHUNIT_COLOR_DEFAULT" fi if bashunit::env::is_verbose_enabled; then printf "Coverage engine: %s\n" "$(bashunit::coverage::engine_in_use)" if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then printf "%sNote: Bash %s.%s does not report lines run inside a subshell; \ coverage may read lower than on Bash 4+.%s\n" \ "$_BASHUNIT_COLOR_INCOMPLETE" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}" \ "$_BASHUNIT_COLOR_DEFAULT" fi fi } 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 "" bashunit::coverage::print_engine_notice 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 stats=$(bashunit::coverage::get_cached_stats "$file") bashunit::coverage::split_stats "$stats" executable="$_BASHUNIT_COVERAGE_SPLIT_EXEC_OUT" hit="$_BASHUNIT_COVERAGE_SPLIT_HIT_OUT" pct="$_BASHUNIT_COVERAGE_SPLIT_PCT_OUT" class="$_BASHUNIT_COVERAGE_SPLIT_CLASS_OUT" total_executable=$((total_executable + executable)) total_hit=$((total_hit + hit)) local color reset="$_BASHUNIT_COLOR_DEFAULT" bashunit::coverage::color_to_slot "$class" color="$_BASHUNIT_COVERAGE_COLOR_OUT" 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 "---------------" local total_pct total_class total_pct=$(bashunit::coverage::calculate_percentage "$total_hit" "$total_executable") bashunit::coverage::class_to_slot "$total_pct" total_class="$_BASHUNIT_COVERAGE_CLASS_OUT" local color reset="$_BASHUNIT_COLOR_DEFAULT" bashunit::coverage::color_to_slot "$total_class" color="$_BASHUNIT_COVERAGE_COLOR_OUT" printf "%sTotal: %d/%d (%d%%)%s\n" \ "$color" "$total_hit" "$total_executable" "$total_pct" "$reset" if [ "${BASHUNIT_COVERAGE_SHOW_FUNCTIONS:-false}" = "true" ]; then bashunit::coverage::report_text_functions fi if [ "${BASHUNIT_COVERAGE_SHOW_UNCOVERED:-false}" = "true" ]; then bashunit::coverage::report_text_uncovered fi if [ "${BASHUNIT_COVERAGE_SHOW_LINE_HITS:-false}" = "true" ]; then bashunit::coverage::report_text_line_hits fi if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then echo "" echo "Coverage report written to: $BASHUNIT_COVERAGE_REPORT" fi } _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%,}" } function bashunit::coverage::report_text_uncovered() { local file local printed_header=false while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue bashunit::coverage::load_hits_by_line "$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="${_BASHUNIT_COVERAGE_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) } function bashunit::coverage::report_text_line_hits() { local IFS=$' \t\n' local file local printed_header=false while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue bashunit::coverage::load_hits_by_line "$file" local -a hit_specs=() local _hc=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="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}" if [ "$lh" -gt 0 ]; then hit_specs[_hc]="${lineno}:${lh}" _hc=$((_hc + 1)) fi done <"$file" [ "$_hc" -eq 0 ] && continue if [ "$printed_header" != "true" ]; then echo "" echo "Line Hits" echo "---------" printed_header=true fi local display_file="${file#"$(pwd)"/}" printf "%s: %s\n" "$display_file" "${hit_specs[*]}" done < <(bashunit::coverage::get_tracked_files) } 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 bashunit::coverage::load_hits_by_line "$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)) [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1)) done fn_pct=0 if [ "$fn_executable" -gt 0 ]; then fn_pct=$((fn_hit * 100 / fn_executable)) fi bashunit::coverage::class_to_slot "$fn_pct" fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT" bashunit::coverage::color_to_slot "$fn_class" color="$_BASHUNIT_COVERAGE_COLOR_OUT" 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) } # src/coverage/diff.sh function bashunit::coverage::is_diff_enabled() { [ -n "${BASHUNIT_COVERAGE_DIFF:-}" ] } function bashunit::coverage::diff_base() { echo "${BASHUNIT_COVERAGE_DIFF:-}" } function bashunit::coverage::diff_percentage() { local total="$1" local hit="$2" if [ "$total" -le 0 ]; then echo "100" return 0 fi echo $((hit * 100 / total)) } function bashunit::coverage::changed_line_stats() { local base="$1" local file="$2" local changed changed="$(bashunit::helper::git_changed_lines "$base" "$file")" if [ -z "$changed" ]; then echo "0:0" return 0 fi local -a src=() local _i=0 _l while IFS= read -r _l || [ -n "$_l" ]; do src[_i]="$_l" _i=$((_i + 1)) done <"$file" local total=0 hit=0 lineno content for lineno in $changed; do content="${src[$((lineno - 1))]:-}" if bashunit::coverage::is_executable_line "$content" "$lineno"; then total=$((total + 1)) if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}" -gt 0 ]; then hit=$((hit + 1)) fi fi done echo "${total}:${hit}" } function bashunit::coverage::diff_files() { local base=$1 local changed changed="$(bashunit::helper::git_changed_files "$base")" [ -n "$changed" ] || return 0 local tracked tracked="$(bashunit::coverage::get_tracked_files)" local file normalized while IFS= read -r file; do [ -n "$file" ] || continue [ -f "$file" ] || continue normalized="$(bashunit::coverage::normalize_path "$file")" case "$file" in *.sh) ;; *) case " $tracked " in *" $normalized "*) ;; *) continue ;; esac ;; esac if bashunit::coverage::should_track "$file"; then printf '%s\n' "$normalized" fi done <"$output_file" return 0 fi if bashunit::coverage::awk_lcov_report "$_BASHUNIT_COVERAGE_MANIFEST_OUT" \ >"$output_file" 2>/dev/null && [ -s "$output_file" ]; then return 0 fi fi { echo "TN:" while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue echo "SF:$file" bashunit::coverage::load_hits_by_line "$file" 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 case "$fn_start$fn_end" in '' | *[!0-9]*) continue ;; esac echo "FN:${fn_start},${fn_name}" fn_total=$((fn_total + 1)) any_hit=0 for ((fln = fn_start; fln <= fn_end; fln++)); do if [ "${_BASHUNIT_COVERAGE_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" 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" bashunit::coverage::awk_lcov_lines "$file" echo "end_of_record" done < <(bashunit::coverage::get_tracked_files) } >"$output_file" } # src/coverage/report_cobertura.sh _BASHUNIT_COBERTURA_RATE_OUT="" function bashunit::coverage::__cobertura_rate() { local hit="$1" local total="$2" local pct=0 if [ "$total" -gt 0 ]; then pct=$((hit * 100 / total)) fi _BASHUNIT_COBERTURA_RATE_OUT="$((pct / 100)).$(printf '%02d' "$((pct % 100))")" } function bashunit::coverage::report_cobertura() { local output_file="${1:-$BASHUNIT_COVERAGE_REPORT_COBERTURA}" if [ -z "$output_file" ]; then return 0 fi mkdir -p "$(dirname "$output_file")" local timestamp timestamp=$(date +%s) local pkg_names pkg_exec pkg_hit pkg_br_total pkg_br_taken pkg_classes pkg_names=() pkg_exec=() pkg_hit=() pkg_br_total=() pkg_br_taken=() pkg_classes=() local total_exec=0 total_hit=0 total_br=0 total_br_taken=0 local file while IFS= read -r file; do { [ -z "$file" ] || [ ! -f "$file" ]; } && continue local rel="${file#"$PWD"/}" local class_name="${rel##*/}" local pkg_name case "$rel" in */*) pkg_name="${rel%/*}" ;; *) pkg_name="." ;; esac pkg_name="${pkg_name//\//.}" bashunit::coverage::load_hits_by_line "$file" local -a br_arms=() local -a br_arms_taken=() local br_line br_block br_idx br_taken local file_br=0 file_br_taken=0 while IFS='|' read -r br_line br_block br_idx br_taken; do [ -z "$br_line" ] && continue br_arms[br_line]=$((${br_arms[br_line]:-0} + 1)) file_br=$((file_br + 1)) if [ "$br_taken" -gt 0 ]; then br_arms_taken[br_line]=$((${br_arms_taken[br_line]:-0} + 1)) file_br_taken=$((file_br_taken + 1)) fi done < <(bashunit::coverage::compute_branch_hits "$file") local -a src_lines=() local _sli=0 _sl while IFS= read -r _sl || [ -n "$_sl" ]; do src_lines[_sli]="$_sl" _sli=$((_sli + 1)) done <"$file" local lines_xml="" lineno=0 file_exec=0 file_hit=0 line for line in ${src_lines[@]+"${src_lines[@]}"}; do lineno=$((lineno + 1)) bashunit::coverage::is_executable_line "$line" "$lineno" || continue file_exec=$((file_exec + 1)) local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}" [ "$lh" -gt 0 ] && file_hit=$((file_hit + 1)) local arms="${br_arms[$lineno]:-0}" if [ "$arms" -gt 0 ]; then local taken="${br_arms_taken[$lineno]:-0}" local cond_pct=$((taken * 100 / arms)) lines_xml="$lines_xml " else lines_xml="$lines_xml " fi done total_exec=$((total_exec + file_exec)) total_hit=$((total_hit + file_hit)) total_br=$((total_br + file_br)) total_br_taken=$((total_br_taken + file_br_taken)) local p=-1 j for j in ${pkg_names[@]+"${!pkg_names[@]}"}; do if [ "${pkg_names[$j]}" = "$pkg_name" ]; then p=$j break fi done if [ "$p" -eq -1 ]; then p=${#pkg_names[@]} pkg_names[p]="$pkg_name" pkg_exec[p]=0 pkg_hit[p]=0 pkg_br_total[p]=0 pkg_br_taken[p]=0 pkg_classes[p]="" fi pkg_exec[p]=$((pkg_exec[p] + file_exec)) pkg_hit[p]=$((pkg_hit[p] + file_hit)) pkg_br_total[p]=$((pkg_br_total[p] + file_br)) pkg_br_taken[p]=$((pkg_br_taken[p] + file_br_taken)) local class_line_rate class_branch_rate bashunit::coverage::__cobertura_rate "$file_hit" "$file_exec" class_line_rate=$_BASHUNIT_COBERTURA_RATE_OUT bashunit::coverage::__cobertura_rate "$file_br_taken" "$file_br" class_branch_rate=$_BASHUNIT_COBERTURA_RATE_OUT pkg_classes[p]="${pkg_classes[p]} $lines_xml " done < <(bashunit::coverage::get_tracked_files) local line_rate branch_rate bashunit::coverage::__cobertura_rate "$total_hit" "$total_exec" line_rate=$_BASHUNIT_COBERTURA_RATE_OUT bashunit::coverage::__cobertura_rate "$total_br_taken" "$total_br" branch_rate=$_BASHUNIT_COBERTURA_RATE_OUT { echo '' echo "" echo " " echo " $PWD" echo " " echo " " local s for s in ${pkg_names[@]+"${!pkg_names[@]}"}; do local pkg_line_rate pkg_branch_rate bashunit::coverage::__cobertura_rate "${pkg_hit[$s]}" "${pkg_exec[$s]}" pkg_line_rate=$_BASHUNIT_COBERTURA_RATE_OUT bashunit::coverage::__cobertura_rate "${pkg_br_taken[$s]}" "${pkg_br_total[$s]}" pkg_branch_rate=$_BASHUNIT_COBERTURA_RATE_OUT echo " " echo " " printf '%s' "${pkg_classes[$s]}" echo " " echo " " done echo " " echo "" } >"$output_file" } # src/coverage/report_html.sh function bashunit::coverage::html_escape() { local text="$1" printf "%s" "$text" | sed "s/&/\&/g; s//\>/g" } _BASHUNIT_COVERAGE_AWK_HTML_ESCAPE=' { gsub(/&/, "\\&") gsub(//, "\\>") print } ' function bashunit::coverage::emit_block() { local _line while IFS= read -r _line || [ -n "$_line" ]; do printf '%s\n' "$_line" done } function bashunit::coverage::html_escape_file() { env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE" "$1" } function bashunit::coverage::report_html() { local output_dir="${1:-coverage/html}" if [ -z "$output_dir" ]; then return 0 fi mkdir -p "$output_dir/files" 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 executable hit pct bashunit::coverage::cached_stats_to_slots "$file" executable="$_BASHUNIT_COVERAGE_SPLIT_EXEC_OUT" hit="$_BASHUNIT_COVERAGE_SPLIT_HIT_OUT" pct="$_BASHUNIT_COVERAGE_SPLIT_PCT_OUT" total_executable=$((total_executable + executable)) total_hit=$((total_hit + hit)) local display_file="${file#"$PWD"/}" bashunit::coverage::path_to_filename_to_slot "$file" local safe_filename="$_BASHUNIT_COVERAGE_SAFE_NAME_OUT" file_data[file_data_count]="$display_file$(printf '\037')$hit$(printf '\037')$executable" file_data[file_data_count]="${file_data[file_data_count]}$(printf '\037')$pct$(printf '\037')$safe_filename" file_data_count=$((file_data_count + 1)) bashunit::coverage::generate_file_html "$file" "$output_dir/files/${safe_filename}.html" done < <(bashunit::coverage::get_tracked_files) local total_pct total_pct=$(bashunit::coverage::calculate_percentage "$total_hit" "$total_executable") 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)) 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" } # src/coverage/html_index.sh function bashunit::coverage::generate_index_html() { 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 local -a file_data=() local file_count=0 if [ $# -gt 0 ]; then file_data=("$@") file_count=$# fi local total_uncovered=$((total_executable - total_hit)) local gauge_offset=$((440 - (440 * total_pct / 100))) local total_class gauge_color_start gauge_color_end gauge_text_gradient bashunit::coverage::class_to_slot "$total_pct" total_class="$_BASHUNIT_COVERAGE_CLASS_OUT" 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 { bashunit::coverage::emit_block <<'EOF' Coverage Report | bashunit
EOF echo "
v${BASHUNIT_VERSION:-0.0.0}
" bashunit::coverage::emit_block <<'EOF'

Code Coverage Report

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

EOF echo " " echo " " bashunit::coverage::emit_block <<'EOF' EOF echo " " bashunit::coverage::emit_block <<'EOF'
EOF echo "
${total_pct}%
" bashunit::coverage::emit_block <<'EOF'
Coverage

Overall Code Coverage

EOF echo "

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

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

File Coverage Details

EOF echo " ≥${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High" bashunit::coverage::emit_block <<'EOF'
EOF echo " ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium" bashunit::coverage::emit_block <<'EOF'
EOF echo " <${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low" bashunit::coverage::emit_block <<'EOF'
EOF local data display_file hit executable pct safe_filename local _us _us=$(printf '\037') for data in ${file_data[@]+"${file_data[@]}"}; do IFS="$_us" read -r display_file hit executable pct safe_filename <<<"$data" local esc_name esc_path esc_name=$(bashunit::str::html_escape "${display_file##*/}") esc_path=$(bashunit::str::html_escape "$display_file") local class bashunit::coverage::class_to_slot "$pct" class="$_BASHUNIT_COVERAGE_CLASS_OUT" echo " " echo " " echo " " echo " " echo " " done bashunit::coverage::emit_block <<'EOF'
File Lines Coverage
" echo "
" echo " ${esc_name}" echo "
./${esc_path}
" echo "
" echo "
" echo "
" echo "
${hit}
" echo "
of ${executable} lines
" echo "
" echo "
" echo "
" echo "
" echo "
" echo "
" echo " ${pct}%" echo "
" echo "
EOF } >"$output_file" } # src/coverage/html_file.sh _BASHUNIT_COVERAGE_AWK_HTML_ROWS=' FILENAME == hitsfile { hits[$1 + 0] = $2 + 0 next } FILENAME == testsfile { # "|:", deduplicated, first-seen order kept. p = index($0, "|") if (p == 0) { next } tln = substr($0, 1, p - 1) + 0 info = substr($0, p + 1) key = tln SUBSEP info if (key in seen) { next } seen[key] = 1 tests[tln] = (tln in tests) ? tests[tln] "\n" info : info next } { total++ sl[total] = $0 } function escape(t) { gsub(/&/, "\\&", t) gsub(//, "\\>", t) return t } END { # The DEBUG trap attributes a multi-line statement to its starting line, so # the count carries forward across the backslash chain (#722). carry = 0 for (ln = 1; ln <= total; ln++) { h = (ln in hits) ? hits[ln] : 0 if (carry > 0 && h < carry) { h = carry; hits[ln] = h } if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 } } for (ln = 1; ln <= total; ln++) { row_class = "" hits_display = "" if (bu_is_executable(sl[ln])) { h = (ln in hits) ? hits[ln] : 0 if (h > 0) { row_class = "covered" if (ln in tests) { tooltip = "
Tests hitting this line
    " n = split(tests[ln], entries, "\n") for (e = 1; e <= n; e++) { if (entries[e] == "") { continue } c = index(entries[e], ":") if (c == 0) { tfile = entries[e]; tfn = "" } else { tfile = substr(entries[e], 1, c - 1); tfn = substr(entries[e], c + 1) } sub(/^.*\//, "", tfile) tooltip = tooltip "
  • " tfile ":" tfn "
  • " } tooltip = tooltip "
" hits_display = "" h times tooltip "" } else { hits_display = "" h times "" } } else { row_class = "uncovered" hits_display = "" h times "" } } printf " \n", ln, row_class printf " %s\n", ln printf " %s\n", hits_display printf " %s\n", escape(sl[ln]) printf " \n" } } ' _BASHUNIT_COVERAGE_AWK_HTML_FUNCTIONS=' FILENAME == hitsfile { hits[$1 + 0] = $2 + 0 next } { total++ sl[total] = $0 } END { carry = 0 for (ln = 1; ln <= total; ln++) { h = (ln in hits) ? hits[ln] : 0 if (carry > 0 && h < carry) { h = carry; hits[ln] = h } if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 } } bu_fn_reset() for (ln = 1; ln <= total; ln++) { bu_fn_line(sl[ln], ln) } bu_fn_finish(total) for (i = 1; i <= fn_count; i++) { executable = 0 hit = 0 for (ln = fns[i]; ln <= fne[i]; ln++) { if (!bu_is_executable(sl[ln])) { continue } executable++ if ((ln in hits) && hits[ln] > 0) { hit++ } } print fnn[i] "|" fns[i] "|" fne[i] "|" executable "|" hit } } ' function bashunit::coverage::html_function_rows() { local file="$1" bashunit::coverage::ensure_hits_aggregated bashunit::coverage::hits_file_for "$file" local hits_file="$_BASHUNIT_COVERAGE_HITS_FILE_OUT" if [ -z "$hits_file" ] || [ ! -f "$hits_file" ]; then hits_file="/dev/null" fi env LC_ALL=C "$AWK" -v hitsfile="$hits_file" \ "${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_FUNCTIONS}\ ${_BASHUNIT_COVERAGE_AWK_HTML_FUNCTIONS}" \ "$hits_file" "$file" } function bashunit::coverage::html_code_rows() { local file="$1" tests_file="$2" bashunit::coverage::ensure_hits_aggregated bashunit::coverage::hits_file_for "$file" local hits_file="$_BASHUNIT_COVERAGE_HITS_FILE_OUT" if [ -z "$hits_file" ] || [ ! -f "$hits_file" ]; then hits_file="/dev/null" fi env LC_ALL=C "$AWK" -v hitsfile="$hits_file" -v testsfile="$tests_file" -v times="×" \ "${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_HTML_ROWS}" \ "$hits_file" "$tests_file" "$file" } function bashunit::coverage::generate_file_html() { local file="$1" local output_file="$2" local display_file="${file#"$PWD"/}" local esc_file esc_base esc_file=$(bashunit::str::html_escape "$display_file") esc_base=$(bashunit::str::html_escape "${display_file##*/}") local executable hit pct class stats stats=$(bashunit::coverage::get_cached_stats "$file") bashunit::coverage::split_stats "$stats" executable="$_BASHUNIT_COVERAGE_SPLIT_EXEC_OUT" hit="$_BASHUNIT_COVERAGE_SPLIT_HIT_OUT" pct="$_BASHUNIT_COVERAGE_SPLIT_PCT_OUT" class="$_BASHUNIT_COVERAGE_SPLIT_CLASS_OUT" local uncovered=$((executable - hit)) bashunit::coverage::load_hits_by_line "$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 tests_file="${_BASHUNIT_COVERAGE_DATA_FILE%/*}/page-tests" if ! bashunit::coverage::get_all_line_tests "$file" >"$tests_file" 2>/dev/null; then : >"$tests_file" fi local total_lines="${#file_lines[@]}" local non_executable=$((total_lines - executable)) { bashunit::coverage::emit_block <<'EOF' EOF echo " ${esc_base} | Coverage Report" bashunit::coverage::emit_block <<'EOF'
EOF echo " ${pct}%" bashunit::coverage::emit_block <<'EOF' Coverage
EOF echo " ${hit}/${executable}" bashunit::coverage::emit_block <<'EOF' Lines
Line Coverage Progress EOF echo " ${pct}%" bashunit::coverage::emit_block <<'EOF'
EOF echo "
" bashunit::coverage::emit_block <<'EOF'
EOF echo " ${hit} lines covered" bashunit::coverage::emit_block <<'EOF'
EOF echo " ${uncovered} lines uncovered" bashunit::coverage::emit_block <<'EOF'
EOF echo " ${non_executable} non-executable" bashunit::coverage::emit_block <<'EOF'
EOF local functions_data functions_data=$(bashunit::coverage::html_function_rows "$file") if [ -n "$functions_data" ]; then bashunit::coverage::emit_block <<'EOF'
EOF local fn_entry while IFS= read -r fn_entry; do [ -z "$fn_entry" ] && continue local fn_name fn_start fn_executable fn_hit rest fn_name="${fn_entry%%|*}" rest="${fn_entry#*|}" fn_start="${rest%%|*}" rest="${rest#*|}" rest="${rest#*|}" fn_executable="${rest%%|*}" fn_hit="${rest#*|}" local fn_pct fn_class row_class fn_pct=0 if [ "$fn_executable" -gt 0 ]; then fn_pct=$((fn_hit * 100 / fn_executable)) fi bashunit::coverage::class_to_slot "$fn_pct" fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT" 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" bashunit::coverage::emit_block <<'EOF'
Function Lines Coverage
${fn_name}${fn_hit} / ${fn_executable}" echo "
" echo "
" echo " ${fn_pct}%" echo "
" echo "
EOF fi bashunit::coverage::emit_block <<'EOF'
EOF echo " ./${esc_file}" echo "
" echo " ${total_lines} total lines" echo "
" bashunit::coverage::emit_block <<'EOF'
EOF bashunit::coverage::html_code_rows "$file" "$tests_file" bashunit::coverage::emit_block <<'EOF'
EOF } >"$output_file" } # src/state/index.sh # src/state/counters.sh _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_TESTS_FLAKY=0 _BASHUNIT_ASSERTIONS_PASSED=0 _BASHUNIT_ASSERTIONS_FAILED=0 _BASHUNIT_ASSERTIONS_SKIPPED=0 _BASHUNIT_ASSERTIONS_INCOMPLETE=0 _BASHUNIT_ASSERTIONS_SNAPSHOT=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_tests_flaky() { echo "$_BASHUNIT_TESTS_FLAKY" } function bashunit::state::add_tests_flaky() { ((_BASHUNIT_TESTS_FLAKY++)) || true } function bashunit::state::get_assertions_passed() { echo "$_BASHUNIT_ASSERTIONS_PASSED" } function bashunit::state::add_assertions_passed() { if [ "${_BASHUNIT_ASSERT_ONCE_ACTIVE:-0}" -eq 1 ]; then bashunit::assert::once_is_absorbing && return 0 fi ((_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 } # src/state/duplicates.sh _BASHUNIT_DUPLICATED_FUNCTION_NAMES="" _BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES="" _BASHUNIT_DUPLICATED_FUNCTION_DETAILS="" _BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND=false 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::get_duplicated_function_details() { echo "$_BASHUNIT_DUPLICATED_FUNCTION_DETAILS" } function bashunit::state::set_duplicated_function_details() { _BASHUNIT_DUPLICATED_FUNCTION_DETAILS="$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" bashunit::state::set_duplicated_function_details "${3:-}" } # src/state/context.sh _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::add_test_output() { _BASHUNIT_TEST_OUTPUT="$_BASHUNIT_TEST_OUTPUT$1" } function bashunit::state::set_test_exit_code() { _BASHUNIT_TEST_EXIT_CODE="$1" } function bashunit::state::set_test_title() { _BASHUNIT_TEST_TITLE="$1" } function bashunit::state::reset_test_title() { _BASHUNIT_TEST_TITLE="" } 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::set_test_hook_failure() { _BASHUNIT_TEST_HOOK_FAILURE="$1" } function bashunit::state::set_test_hook_message() { _BASHUNIT_TEST_HOOK_MESSAGE="$1" } function bashunit::state::mark_assertion_failed_in_test() { _BASHUNIT_ASSERTION_FAILED_IN_TEST=1 } 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 bashunit::assert::once_reset } _BASHUNIT_STATE_ENCODED_OUT="" # src/state/payload.sh _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_BASE64_EMPTY_SENTINEL="_BASHUNIT_EMPTY_" 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 _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 local payload="\ ##ASSERTIONS_FAILED=$_BASHUNIT_ASSERTIONS_FAILED##ASSERTIONS_PASSED=$_BASHUNIT_ASSERTIONS_PASSED##ASSERTIONS_SKIPPED=$_BASHUNIT_ASSERTIONS_SKIPPED##ASSERTIONS_INCOMPLETE=$_BASHUNIT_ASSERTIONS_INCOMPLETE##ASSERTIONS_SNAPSHOT=$_BASHUNIT_ASSERTIONS_SNAPSHOT##TEST_EXIT_CODE=$_BASHUNIT_TEST_EXIT_CODE##TEST_HOOK_FAILURE=$_BASHUNIT_TEST_HOOK_FAILURE##TEST_HOOK_MESSAGE=$encoded_test_hook_message##TEST_TITLE=$encoded_test_title##TEST_OUTPUT=$encoded_test_output##" printf '%s\n' "$payload" } # src/state/parallel.sh function bashunit::state::aggregate_parallel_results() { local temp_dir_parallel_test_suite=$1 local IFS=$' \t\n' bashunit::internal_log "aggregate_parallel_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 local result_files result_files=("$script_dir"/*.result) shopt -u nullglob if [ ${#result_files[@]} -eq 0 ]; then printf "%sNo tests found%s\n" "$_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} local retries=0 case "$result_line" in *"##TEST_RETRIES="*) retries="${result_line##*##TEST_RETRIES=}" retries="${retries%%##*}" case "$retries" in '' | *[!0-9]*) retries=0 ;; esac ;; esac case "$failed$passed$skipped$incomplete$snapshot$exit_code" in *[!0-9]*) failed=0 passed=0 skipped=0 incomplete=0 snapshot=0 exit_code=1 bashunit::internal_log "aggregate_parallel_results" "unparseable result file:$result_file" ;; esac 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 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 if [ "$retries" -gt 0 ]; then bashunit::state::add_tests_flaky 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" } # src/console/index.sh # src/console/colors.sh bashunit::sgr() { local codes=${1:-0} shift local c for c in "$@"; do codes="$codes;$c" done echo $'\e'"[${codes}m" } if bashunit::env::is_no_color_enabled; then _BASHUNIT_COLOR_BOLD="" _BASHUNIT_COLOR_FAINT="" _BASHUNIT_COLOR_BLACK="" _BASHUNIT_COLOR_FAILED="" _BASHUNIT_COLOR_PASSED="" _BASHUNIT_COLOR_SKIPPED="" _BASHUNIT_COLOR_INCOMPLETE="" _BASHUNIT_COLOR_SNAPSHOT="" _BASHUNIT_COLOR_RISKY="" _BASHUNIT_COLOR_RETURN_ERROR="" _BASHUNIT_COLOR_RETURN_SUCCESS="" _BASHUNIT_COLOR_RETURN_SKIPPED="" _BASHUNIT_COLOR_RETURN_INCOMPLETE="" _BASHUNIT_COLOR_RETURN_SNAPSHOT="" _BASHUNIT_COLOR_RETURN_RISKY="" _BASHUNIT_COLOR_DEFAULT="" else _BASHUNIT_COLOR_BOLD="$(bashunit::sgr 1)" _BASHUNIT_COLOR_FAINT="$(bashunit::sgr 90)" _BASHUNIT_COLOR_BLACK="$(bashunit::sgr 30)" _BASHUNIT_COLOR_FAILED="$(bashunit::sgr 31)" _BASHUNIT_COLOR_PASSED="$(bashunit::sgr 32)" _BASHUNIT_COLOR_SKIPPED="$(bashunit::sgr 33)" _BASHUNIT_COLOR_INCOMPLETE="$(bashunit::sgr 36)" _BASHUNIT_COLOR_SNAPSHOT="$(bashunit::sgr 34)" _BASHUNIT_COLOR_RISKY="$(bashunit::sgr 35)" _BASHUNIT_COLOR_RETURN_ERROR="$(bashunit::sgr 41)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" _BASHUNIT_COLOR_RETURN_SUCCESS="$(bashunit::sgr 42)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" _BASHUNIT_COLOR_RETURN_SKIPPED="$(bashunit::sgr 43)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" _BASHUNIT_COLOR_RETURN_INCOMPLETE="$(bashunit::sgr 46)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" _BASHUNIT_COLOR_RETURN_SNAPSHOT="$(bashunit::sgr 44)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" _BASHUNIT_COLOR_RETURN_RISKY="$(bashunit::sgr 45)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" _BASHUNIT_COLOR_DEFAULT="$(bashunit::sgr 0)" fi # src/console/header.sh function bashunit::console_header::print_version_with_env() { local filter=${1:-} shift || true if ! bashunit::env::is_show_header_enabled; then return fi bashunit::console_header::print_version "$filter" "$@" if bashunit::env::is_dev_mode_enabled; then printf "%sDev log:%s %s\n" \ "${_BASHUNIT_COLOR_INCOMPLETE}" "${_BASHUNIT_COLOR_DEFAULT}" "$BASHUNIT_DEV_LOG" fi } 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 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 total_tests=0 else 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 --exclude-filter Skip tests whose name matches (repeatable) --suite Run a [suite:] from .bashunitrc (repeatable) --list-suites Print the suites defined in .bashunitrc and exit --tag Only run tests with matching @tag (repeatable, OR logic). Supports 'a&&b' (AND) and '!a' (NOT) --exclude-tag Skip tests with matching @tag (repeatable, exclude wins) --sandbox Fail a test that runs an external command it did not mock --sandbox-allow Commands the sandbox still allows (repeatable) --log-junit, --report-junit Write JUnit XML report --log-gha Write GitHub Actions annotations to a file --gha-annotations Annotations on stdout: auto (in GitHub Actions), always or never -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 --report-md Write a Markdown summary (auto-appended to \$GITHUB_STEP_SUMMARY) -s, --simple Simple output (dots) --detailed Detailed output (default) --output Report on stdout: text (default), tap, json or junit -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) --repeat Run each selected test N times; it fails if any iteration fails --random-order Randomize test execution order --order-by Execution order: defined (default), defects (last run's failures first) or random --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) --changed [] Run only the test files changed since (default: origin/HEAD, then HEAD) --list, --dry-run Print the tests that would run, then exit without running them --list-format Rendering for --list: text (default) or json --snapshot-update Rewrite existing snapshots from the actual value (combine with --filter) --no-snapshot-create Fail on a missing snapshot instead of recording it (for CI) --snapshot-report-unused List snapshot files no test resolved (full runs only, deletes nothing) --snapshot-prune Delete the snapshot files no test resolved (full runs only) -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) --show-skipped Show the skipped tests summary at the end --show-incomplete Show the incomplete tests summary at the end --fail-on-risky Treat risky tests (no assertions) as failures --fail-on-flaky Treat flaky tests (passed only after a retry) 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-report-cobertura [file] Cobertura XML for GitLab/Azure/Jenkins (default: coverage/cobertura.xml) --coverage-min Fail if coverage below percentage --coverage-diff Report coverage only for lines changed since ref --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 --baseline Compare against a previous --report-json result --baseline-tolerance Allowed regression before failing (default: 10) --baseline-update Write this run as the new baseline --report-json Write machine-readable benchmark results --report-junit Write JUnit XML benchmark results -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" bashunit bench --report-json bench.json bashunit bench --baseline bench.json --baseline-tolerance 5 EOF } function bashunit::console_header::print_doc_help() { cat < Load a bootstrap file defining custom assertions Examples: bashunit doc Show all assertions bashunit doc equals Show assertions containing 'equals' bashunit doc file Show file-related assertions bashunit doc --custom Show only your project's own assertions EOF } function bashunit::console_header::print_init_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 } # src/console/line.sh _BASHUNIT_TOTAL_TESTS_COUNT=0 function bashunit::console_results::print_line() { local type=$1 local line=$2 ((_BASHUNIT_TOTAL_TESTS_COUNT++)) || true bashunit::state::add_test_output "[$type]$line" if bashunit::env::is_no_progress_enabled; then return fi if bashunit::env::is_tap_output_enabled; then bashunit::console_results::print_tap_line "$type" "$line" return fi if bashunit::env::is_machine_output_enabled; then return fi if ! bashunit::env::is_simple_output_enabled; then printf "%s\n" "$line" return fi local char case "$type" in successful) char="." ;; failure) char="${_BASHUNIT_COLOR_FAILED}F${_BASHUNIT_COLOR_DEFAULT}" ;; failed) char="${_BASHUNIT_COLOR_FAILED}F${_BASHUNIT_COLOR_DEFAULT}" ;; failed_snapshot) char="${_BASHUNIT_COLOR_FAILED}F${_BASHUNIT_COLOR_DEFAULT}" ;; skipped) char="${_BASHUNIT_COLOR_SKIPPED}S${_BASHUNIT_COLOR_DEFAULT}" ;; incomplete) char="${_BASHUNIT_COLOR_INCOMPLETE}I${_BASHUNIT_COLOR_DEFAULT}" ;; snapshot) char="${_BASHUNIT_COLOR_SNAPSHOT}N${_BASHUNIT_COLOR_DEFAULT}" ;; risky) char="${_BASHUNIT_COLOR_RISKY}R${_BASHUNIT_COLOR_DEFAULT}" ;; error) char="${_BASHUNIT_COLOR_FAILED}E${_BASHUNIT_COLOR_DEFAULT}" ;; *) char="?" && bashunit::log "warning" "unknown test type '$type'" ;; esac if bashunit::parallel::is_enabled; then printf "%s" "$char" else if ((_BASHUNIT_TOTAL_TESTS_COUNT % 50 == 0)); then printf "%s\n" "$char" else printf "%s" "$char" fi fi } function bashunit::console_results::print_tap_line() { local type=$1 local line=$2 local clean_line clean_line=$(printf "%s" "$line" | sed 's/\x1B\[[0-9;]*[mK]//g') local test_name="${clean_line#*: }" test_name="${test_name%%$'\n'*}" test_name=$(printf "%s" "$test_name" | sed 's/[[:space:]]*[0-9][0-9]*m\{0,1\}[[:space:]]*[0-9.]*[ms]*[[:space:]]*$//') case "$type" in successful) printf "ok %d - %s\n" "$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name" ;; failure | failed | failed_snapshot | error) printf "not ok %d - %s\n" "$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name" local detail_line printf " ---\n" while IFS= read -r detail_line; do detail_line=$(printf "%s" "$detail_line" | sed 's/\x1B\[[0-9;]*[mK]//g') if [ -n "$detail_line" ] && [ "$(echo "$detail_line" | "$GREP" -cF "Failed:" || true)" -eq 0 ] && [ "$(echo "$detail_line" | "$GREP" -cF "Error:" || true)" -eq 0 ]; then local trimmed="${detail_line#"${detail_line%%[![:space:]]*}"}" printf " %s\n" "$trimmed" fi done <<<"$clean_line" printf " ...\n" ;; skipped) local skip_name="${test_name%% *}" local skip_reason="${test_name#"$skip_name"}" skip_reason="${skip_reason#"${skip_reason%%[![:space:]]*}"}" if [ -n "$skip_reason" ]; then printf "ok %d - %s # SKIP %s\n" \ "$_BASHUNIT_TOTAL_TESTS_COUNT" "$skip_name" "$skip_reason" else printf "ok %d - %s # SKIP\n" \ "$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name" fi ;; incomplete) printf "ok %d - %s # TODO incomplete\n" \ "$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name" ;; snapshot) printf "ok %d - %s # snapshot\n" \ "$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name" ;; risky) printf "ok %d - %s # RISKY no assertions\n" \ "$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name" ;; *) printf "not ok %d - %s\n" \ "$_BASHUNIT_TOTAL_TESTS_COUNT" "$test_name" ;; esac } # src/console/duration.sh function bashunit::console_results::format_duration_to_slot() { 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)) _BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s" elif [ "$duration_ms" -ge 1000 ]; then local integer_part=$((duration_ms / 1000)) local decimal_part=$(((duration_ms % 1000) / 10)) if [ "$decimal_part" -lt 10 ]; then decimal_part="0${decimal_part}" fi _BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s" else _BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms" fi } function bashunit::console_results::format_duration() { bashunit::console_results::format_duration_to_slot "$1" echo "$_BASHUNIT_CONSOLE_DURATION_OUT" } # src/console/diff.sh 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 --no-index --no-ext-diff --word-diff "$color_flag" \ "$expected_file" "$actual_file" 2>/dev/null | tail -n +6 | sed "s/^/ /" || true } 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::snapshot_line_diff() { local expected=$1 local actual=$2 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 <"$_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 if [ -n "$details" ]; then line="$line $details" fi line="$line$(bashunit::console_results::test_location_suffix)" bashunit::console_results::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} ${_BASHUNIT_COLOR_FAINT}Snapshot: %s${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_FAINT}Re-record with '--snapshot-update'${_BASHUNIT_COLOR_DEFAULT}\n" \ "$function_name" "$snapshot_file")" 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::console_results::print_line "failed_snapshot" "$line" } function bashunit::console_results::print_skipped_test() { local function_name=$1 local reason=${2-} local line line="$(printf "${_BASHUNIT_COLOR_SKIPPED}↷ Skipped${_BASHUNIT_COLOR_DEFAULT}: %s\n" "${function_name}")" if [ -n "$reason" ]; then line="$line$(printf "${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}\n" "${reason}")" fi bashunit::console_results::print_line "skipped" "$line" } function bashunit::console_results::print_incomplete_test() { local function_name=$1 local pending=${2-} local line line="$(printf "${_BASHUNIT_COLOR_INCOMPLETE}✒ Incomplete${_BASHUNIT_COLOR_DEFAULT}: %s\n" "${function_name}")" if [ -n "$pending" ]; then line="$line$(printf "${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}\n" "${pending}")" fi bashunit::console_results::print_line "incomplete" "$line" } function bashunit::console_results::print_snapshot_test() { local function_name=$1 local test_name test_name=$(bashunit::helper::normalize_test_function_name "$function_name") local line line="$(printf "${_BASHUNIT_COLOR_SNAPSHOT}✎ Snapshot${_BASHUNIT_COLOR_DEFAULT}: %s\n" "${test_name}")" bashunit::console_results::print_line "snapshot" "$line" } function bashunit::console_results::print_risky_test() { local test_name=$1 local duration=${2:-"0"} local line line=$(printf "%s⚠ Risky%s: %s" "$_BASHUNIT_COLOR_RISKY" "$_BASHUNIT_COLOR_DEFAULT" "$test_name") local full_line=$line if bashunit::env::is_show_execution_time_enabled; then local time_display time_display=$(bashunit::console_results::format_duration "$duration") full_line="$(bashunit::str::rpad "$line" "$time_display")" fi bashunit::console_results::print_line "risky" "$full_line" } function bashunit::console_results::print_error_test() { local function_name=$1 local error="$2" local raw_output="${3:-}" local test_name test_name=$(bashunit::helper::normalize_test_function_name "$function_name") local line line="$(printf "${_BASHUNIT_COLOR_FAILED}✗ Error${_BASHUNIT_COLOR_DEFAULT}: %s ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT}\n" "${test_name}" "${error}")" if [ -n "$raw_output" ] && bashunit::env::is_show_output_on_failure_enabled; then line="$line$(printf " %sOutput:%s\n" "${_BASHUNIT_COLOR_FAINT}" "${_BASHUNIT_COLOR_DEFAULT}")" local output_line while IFS= read -r output_line; do line="$line$(printf " %s\n" "$output_line")" done <<<"$raw_output" fi line="$line$(bashunit::console_results::test_location_suffix)" bashunit::console_results::print_line "error" "$line" } function bashunit::console_results::print_worker_stderr() { local test_file="$1" local stderr_file="$2" printf "\n%sStderr from %s%s\n" \ "$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT" sed 's/^/|/' "$stderr_file" } # src/console/deferred.sh function bashunit::console_results::print_failing_tests_and_reset() { if [ -s "$FAILURES_OUTPUT_PATH" ]; then local total_failed total_failed=$(bashunit::state::get_tests_failed) if bashunit::env::is_simple_output_enabled; then printf "\n\n" fi if [ "$total_failed" -eq 1 ]; then echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n" else echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n" fi sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/' rm "$FAILURES_OUTPUT_PATH" echo "" fi } function bashunit::console_results::print_profile_and_reset() { if [ ! -s "$PROFILE_OUTPUT_PATH" ]; then rm -f "$PROFILE_OUTPUT_PATH" return fi local count="${BASHUNIT_PROFILE_COUNT:-10}" echo -e "\n${_BASHUNIT_COLOR_BOLD}Slowest tests:${_BASHUNIT_COLOR_DEFAULT}" local duration name file formatted while IFS=$'\t' read -r duration name file; do formatted=$(bashunit::console_results::format_duration "$duration") printf " %s\t%s (%s)\n" "$formatted" "$name" "$file" done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "$count") echo "" rm -f "$PROFILE_OUTPUT_PATH" } function bashunit::console_results::flush_deferred_block() { local output_path=$1 local total=$2 local singular=$3 local plural=$4 if bashunit::env::is_simple_output_enabled; then printf "\n" fi if [ "$total" -eq 1 ]; then echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 ${singular}:${_BASHUNIT_COLOR_DEFAULT}\n" else echo -e "${_BASHUNIT_COLOR_BOLD}There were ${total} ${plural}:${_BASHUNIT_COLOR_DEFAULT}\n" fi tr -d '\r' <"$output_path" | sed '/^[[:space:]]*$/d' | sed 's/^/|/' rm "$output_path" echo "" } function bashunit::console_results::print_skipped_tests_and_reset() { if [ -s "$SKIPPED_OUTPUT_PATH" ] && bashunit::env::is_show_skipped_enabled; then bashunit::console_results::flush_deferred_block "$SKIPPED_OUTPUT_PATH" \ "$(bashunit::state::get_tests_skipped)" "skipped test" "skipped tests" fi } function bashunit::console_results::print_incomplete_tests_and_reset() { if [ -s "$INCOMPLETE_OUTPUT_PATH" ] && bashunit::env::is_show_incomplete_enabled; then bashunit::console_results::flush_deferred_block "$INCOMPLETE_OUTPUT_PATH" \ "$(bashunit::state::get_tests_incomplete)" "incomplete test" "incomplete tests" fi } function bashunit::console_results::print_risky_tests_and_reset() { if [ -s "$RISKY_OUTPUT_PATH" ]; then bashunit::console_results::flush_deferred_block "$RISKY_OUTPUT_PATH" \ "$(bashunit::state::get_tests_risky)" "risky test" "risky tests" fi } # src/console/summary.sh function bashunit::console_results::print_filter_hint() { local filter="${_BASHUNIT_ACTIVE_FILTER:-}" [ -n "$filter" ] || return 0 local needle="${filter#test_}" local suggestion="" if [ -n "$needle" ]; then local lowered lowered="$(printf '%s' "$needle" | tr '[:upper:]' '[:lower:]')" local underscored="${lowered// /_}" local fn for fn in ${_BASHUNIT_CACHED_ALL_FUNCTIONS:-}; do case "$fn" in test_*"$lowered"* | test_*"$underscored"*) suggestion="$fn" break ;; esac done fi if [ -n "$suggestion" ]; then printf "%sNo test matches '%s'. Filters match the function name, not the title: did you mean '%s'?%s\n" \ "${_BASHUNIT_COLOR_FAINT:-}" "$filter" "$suggestion" "${_BASHUNIT_COLOR_DEFAULT:-}" return 0 fi printf "%sNo test matches '%s'. Filters match the function name (test_...), not the title in the report.%s\n" \ "${_BASHUNIT_COLOR_FAINT:-}" "$filter" "${_BASHUNIT_COLOR_DEFAULT:-}" } 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)" local _dup_detail _dup_detail="$(bashunit::state::get_duplicated_function_details)" if [ -z "$_dup_detail" ]; then _dup_detail="$(bashunit::state::get_duplicated_function_names)" fi printf "Duplicate functions: %s\n" "$_dup_detail" 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_machine_output_enabled; then if [ "$_BASHUNIT_TESTS_FAILED" -gt 0 ]; then return 1 fi if [ "$_BASHUNIT_TESTS_FLAKY" -gt 0 ] && bashunit::env::is_fail_on_flaky_enabled; then return 1 fi local machine_total=$((_BASHUNIT_TESTS_PASSED + _BASHUNIT_TESTS_SKIPPED + \ _BASHUNIT_TESTS_INCOMPLETE + _BASHUNIT_TESTS_SNAPSHOT + \ _BASHUNIT_TESTS_FAILED + _BASHUNIT_TESTS_RISKY)) if [ "$machine_total" -eq 0 ]; then return 1 fi return 0 fi if bashunit::env::is_simple_output_enabled; then printf "\n\n" fi 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 tests_flaky=$_BASHUNIT_TESTS_FLAKY 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 if [ "$tests_flaky" -gt 0 ]; then printf " %s%s flaky%s," "$_BASHUNIT_COLOR_INCOMPLETE" "$tests_flaky" "$_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_flaky" -gt 0 ] && bashunit::env::is_fail_on_flaky_enabled; then printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_ERROR" " Some tests flaky " "$_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 local snapshot_notice=" Some snapshots created " if bashunit::env::is_snapshot_update_enabled; then snapshot_notice=" Some snapshots updated " fi printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_SNAPSHOT" "$snapshot_notice" "$_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_filter_hint 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) time="${time%%.*}" time="${time:-0}" local formatted formatted=$(bashunit::console_results::format_duration "$time") printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" \ "Time taken: ${formatted}" } _BASHUNIT_CONSOLE_DURATION_OUT="" 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_machine_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")" } # src/helper/index.sh # src/helper/naming.sh _BASHUNIT_HELPER_TESTFN_OUT="" function bashunit::helper::find_test_function_name_to_slot() { local fallback_depth="${1:-2}" local i for ((i = 0; i < ${#FUNCNAME[@]}; i++)); do local fn="${FUNCNAME[$i]}" case "$fn" in test_* | test[A-Z]*) _BASHUNIT_HELPER_TESTFN_OUT=$fn return ;; esac done _BASHUNIT_HELPER_TESTFN_OUT=${FUNCNAME[$fallback_depth]:-} } function bashunit::helper::find_test_function_name() { local fallback_depth="${1:-2}" local i for ((i = 0; i < ${#FUNCNAME[@]}; i++)); do local fn="${FUNCNAME[$i]}" case "$fn" in test_* | test[A-Z]*) echo "$fn" return ;; esac done echo "${FUNCNAME[$fallback_depth]:-}" } _BASHUNIT_HELPER_NORMALIZED_OUT="" function bashunit::helper::normalize_test_function_name_to_slot() { local original_fn_name="${1-}" local interpolated_fn_name="${2-}" local custom_title="${_BASHUNIT_TEST_TITLE:-}" if [ -n "$custom_title" ]; then _BASHUNIT_HELPER_NORMALIZED_OUT=$custom_title return fi if [ -z "${interpolated_fn_name-}" ]; then case "${original_fn_name}" in *"::"*) local state_interpolated_fn_name="${_BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME:-}" if [ -n "$state_interpolated_fn_name" ]; then interpolated_fn_name="$state_interpolated_fn_name" fi ;; esac fi if [ -n "${interpolated_fn_name-}" ]; then original_fn_name="$interpolated_fn_name" fi local result result="${original_fn_name#test_}" if [ "$result" = "$original_fn_name" ]; then result="${original_fn_name#test}" fi result="${result//_/ }" local first_char="${result:0:1}" case "$first_char" in a) first_char='A' ;; b) first_char='B' ;; c) first_char='C' ;; d) first_char='D' ;; e) first_char='E' ;; f) first_char='F' ;; g) first_char='G' ;; h) first_char='H' ;; i) first_char='I' ;; j) first_char='J' ;; k) first_char='K' ;; l) first_char='L' ;; m) first_char='M' ;; n) first_char='N' ;; o) first_char='O' ;; p) first_char='P' ;; q) first_char='Q' ;; r) first_char='R' ;; s) first_char='S' ;; t) first_char='T' ;; u) first_char='U' ;; v) first_char='V' ;; w) first_char='W' ;; x) first_char='X' ;; y) first_char='Y' ;; z) first_char='Z' ;; esac result="${first_char}${result:1}" _BASHUNIT_HELPER_NORMALIZED_OUT=$result } function bashunit::helper::normalize_test_function_name() { bashunit::helper::normalize_test_function_name_to_slot "${1-}" "${2-}" echo "$_BASHUNIT_HELPER_NORMALIZED_OUT" } function bashunit::helper::escape_single_quotes() { local value="$1" echo "${value//\'/'\'\\''\'}" } function bashunit::helper::interpolate_function_name() { local function_name="$1" shift case "$function_name" in *::*) ;; *) echo "$function_name" return ;; esac local -a args local args_count=$# args=("$@") local result="$function_name" local i for ((i = 0; i < args_count; i++)); do local placeholder="::$((i + 1))::" local value="$(bashunit::helper::escape_single_quotes "${args[$i]}")" value="'$value'" result="${result//${placeholder}/${value}}" done echo "$result" } function bashunit::helper::normalize_variable_name_to_slot() { local input_string="$1" local normalized_string="${input_string//[^a-zA-Z0-9_]/_}" 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" } _BASHUNIT_PROVIDER_MAP_SCRIPT="" _BASHUNIT_PROVIDER_MAP_FNS=() _BASHUNIT_PROVIDER_MAP_PROVIDERS=() _BASHUNIT_PROVIDER_FN_OUT="" _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false # src/helper/encoding.sh function bashunit::helper::encode_base64() { local value="$1" if [ -z "$value" ]; then printf '%s' "$_BASHUNIT_BASE64_EMPTY_SENTINEL" return fi if [ "$_BASHUNIT_BASE64_WRAP_FLAG" = true ]; then printf '%s' "$value" | base64 -w 0 elif command -v base64 >/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" if [ -z "$value" ] || [ "$value" = "$_BASHUNIT_BASE64_EMPTY_SENTINEL" ]; 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 } _BASHUNIT_HELPER_ID_OUT="" function bashunit::helper::generate_id() { local basename="$1" 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 } # src/helper/functions.sh 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 } function bashunit::helper::unset_if_exists() { unset "$1" 2>/dev/null } # src/helper/git.sh declare -r BASHUNIT_GIT_REPO="https://github.com/TypedDevs/bashunit" function bashunit::helper::get_latest_tag() { if ! bashunit::dependencies::has_git; then return 1 fi 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 } function bashunit::helper::git_is_repo() { if ! bashunit::dependencies::has_git; then return 1 fi git rev-parse --is-inside-work-tree >/dev/null 2>&1 } function bashunit::helper::git_ref_exists() { git rev-parse --verify --quiet "$1^{commit}" >/dev/null 2>&1 } function bashunit::helper::git_changed_ref() { if [ -n "${BASHUNIT_CHANGED_REF:-}" ]; then echo "$BASHUNIT_CHANGED_REF" elif bashunit::helper::git_ref_exists "origin/HEAD"; then echo "origin/HEAD" else echo "HEAD" fi } function bashunit::helper::git_changed_files() { local ref=$1 local prefix prefix="$(git rev-parse --show-prefix 2>/dev/null)" { git -c core.quotePath=false diff -M --name-only --diff-filter=d "$ref...HEAD" 2>/dev/null git -c core.quotePath=false diff -M --name-only --diff-filter=d HEAD 2>/dev/null git -c core.quotePath=false ls-files --others --exclude-standard 2>/dev/null } | awk -v prefix="$prefix" ' NF == 0 { next } prefix != "" { if (index($0, prefix) != 1) next $0 = substr($0, length(prefix) + 1) } !seen[$0]++' } function bashunit::helper::git_changed_lines() { local ref=$1 local file=$2 if git ls-files --error-unmatch -- "$file" >/dev/null 2>&1; then { git diff --unified=0 -M "$ref...HEAD" -- "$file" 2>/dev/null git diff --unified=0 -M HEAD -- "$file" 2>/dev/null } | awk ' /^@@ / { # @@ -old,count +new,count @@ plus = $3 sub(/^\+/, "", plus) n = index(plus, ",") if (n == 0) { start = plus + 0; len = 1 } else { start = substr(plus, 1, n - 1) + 0; len = substr(plus, n + 1) + 0 } for (i = 0; i < len; i++) { seen[start + i] = 1 } } END { for (l in seen) { print l + 0 } } ' | sort -n -u return 0 fi if [ -f "$file" ] && [ -n "$(git ls-files --others --exclude-standard -- "$file" 2>/dev/null)" ]; then awk 'END { for (i = 1; i <= NR; i++) print i }' "$file" fi } function bashunit::helper::git_filter_changed() { local ref=$1 shift local changed changed="$(bashunit::helper::git_changed_files "$ref")" [ -n "$changed" ] || return 0 local file normalized for file in "$@"; do normalized="${file#./}" case " $changed " in *" $normalized "*) printf '%s\n' "$file" ;; esac done } _BASHUNIT_HELPER_TOTAL_TESTS_OUT=0 # src/helper/annotations.sh _BASHUNIT_ANNOT_MAP_FNS=() _BASHUNIT_ANNOT_MAP_TIMEOUTS=() _BASHUNIT_ANNOT_MAP_RETRIES=() _BASHUNIT_ANNOT_MAP_SKIPS=() _BASHUNIT_ANNOT_MAP_REASONS=() _BASHUNIT_ANNOT_TIMEOUT_OUT="" _BASHUNIT_ANNOT_RETRY_OUT="" _BASHUNIT_ANNOT_SKIP_OUT="false" _BASHUNIT_ANNOT_REASON_OUT="" function bashunit::helper::annotations_reset() { _BASHUNIT_ANNOT_MAP_FNS=() _BASHUNIT_ANNOT_MAP_TIMEOUTS=() _BASHUNIT_ANNOT_MAP_RETRIES=() _BASHUNIT_ANNOT_MAP_SKIPS=() _BASHUNIT_ANNOT_MAP_REASONS=() } function bashunit::helper::annotations_record() { local count=${#_BASHUNIT_ANNOT_MAP_FNS[@]} _BASHUNIT_ANNOT_MAP_FNS[count]="$1" _BASHUNIT_ANNOT_MAP_TIMEOUTS[count]="${2-}" _BASHUNIT_ANNOT_MAP_RETRIES[count]="${3-}" _BASHUNIT_ANNOT_MAP_SKIPS[count]="${4-}" _BASHUNIT_ANNOT_MAP_REASONS[count]="${5-}" } function bashunit::helper::annotations_for_function() { local function_name=$1 local i=0 local total=${#_BASHUNIT_ANNOT_MAP_FNS[@]} _BASHUNIT_ANNOT_TIMEOUT_OUT="" _BASHUNIT_ANNOT_RETRY_OUT="" _BASHUNIT_ANNOT_SKIP_OUT="false" _BASHUNIT_ANNOT_REASON_OUT="" while [ "$i" -lt "$total" ]; do if [ "${_BASHUNIT_ANNOT_MAP_FNS[i]}" = "$function_name" ]; then _BASHUNIT_ANNOT_TIMEOUT_OUT="${_BASHUNIT_ANNOT_MAP_TIMEOUTS[i]}" _BASHUNIT_ANNOT_RETRY_OUT="${_BASHUNIT_ANNOT_MAP_RETRIES[i]}" if [ "${_BASHUNIT_ANNOT_MAP_SKIPS[i]}" = "1" ]; then _BASHUNIT_ANNOT_SKIP_OUT="true" fi _BASHUNIT_ANNOT_REASON_OUT="${_BASHUNIT_ANNOT_MAP_REASONS[i]}" return fi i=$((i + 1)) done } function bashunit::helper::_annotations_is_count() { case "$1" in '' | *[!0-9]*) return 1 ;; esac return 0 } function bashunit::helper::annotations_validate_or_exit() { local script=$1 local i=0 local total=${#_BASHUNIT_ANNOT_MAP_FNS[@]} local fn value while [ "$i" -lt "$total" ]; do fn="${_BASHUNIT_ANNOT_MAP_FNS[i]}" value="${_BASHUNIT_ANNOT_MAP_TIMEOUTS[i]}" if [ -n "$value" ] && ! bashunit::helper::_annotations_is_count "$value"; then bashunit::helper::_annotations_reject "$script" "$fn" "timeout" "$value" fi value="${_BASHUNIT_ANNOT_MAP_RETRIES[i]}" if [ -n "$value" ] && ! bashunit::helper::_annotations_is_count "$value"; then bashunit::helper::_annotations_reject "$script" "$fn" "retry" "$value" fi i=$((i + 1)) done } function bashunit::helper::_annotations_reject() { printf "%sError: @%s '%s' above %s in %s is not a non-negative integer.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$3" "$4" "$2" "$1" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 } # src/helper/provider.sh _BASHUNIT_PROVIDER_RESOLVED_OUT="" function bashunit::helper::_resolve_provider_script() { local script=$1 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 } function bashunit::helper::build_provider_map() { bashunit::helper::_resolve_provider_script "$1" local script=$_BASHUNIT_PROVIDER_RESOLVED_OUT if [ -z "$script" ]; then _BASHUNIT_PROVIDER_MAP_SCRIPT="$1" _BASHUNIT_PROVIDER_MAP_FNS=() _BASHUNIT_PROVIDER_MAP_PROVIDERS=() _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false bashunit::helper::annotations_reset 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 bashunit::helper::annotations_reset local count=0 local fn provider annot_timeout annot_retry annot_skip annot_reason while IFS=$'\t' read -r fn provider annot_timeout annot_retry annot_skip annot_reason; do [ -z "$fn" ] && continue if [ "$fn" = "@@no_parallel@@" ]; then [ "$provider" = "1" ] && _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=true continue fi if [ "$fn" = "@@annot@@" ]; then [ "$annot_timeout" = "@@none@@" ] && annot_timeout="" [ "$annot_retry" = "@@none@@" ] && annot_retry="" bashunit::helper::annotations_record \ "$provider" "$annot_timeout" "$annot_retry" "$annot_skip" "$annot_reason" 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 } /^[[:space:]]*#[[:space:]]*@timeout([[:space:]]|=)/ { v = $0 sub(/^[[:space:]]*#[[:space:]]*@timeout[[:space:]=]+/, "", v) sub(/[[:space:]]+$/, "", v) a_timeout = v next } /^[[:space:]]*#[[:space:]]*@retry([[:space:]]|=)/ { v = $0 sub(/^[[:space:]]*#[[:space:]]*@retry[[:space:]=]+/, "", v) sub(/[[:space:]]+$/, "", v) a_retry = v next } /^[[:space:]]*#[[:space:]]*@skip([[:space:]]|$)/ { v = $0 sub(/^[[:space:]]*#[[:space:]]*@skip[[:space:]]*/, "", v) sub(/[[:space:]]+$/, "", v) a_skip = 1 a_reason = v next } # Any other comment keeps the block open, the same rule @tag follows. /^[[:space:]]*#/ { next } { is_fn = match($0, /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/) if (is_fn) { fn = $0 sub(/^[[:space:]]*(function[[:space:]]+)?/, "", fn) sub(/[[:space:]]*\(\).*/, "", fn) } if (pending != "" && NR - pending_line <= 2) { if (is_fn) { printf "%s\t%s\n", fn, pending pending = "" } } else if (pending != "" && NR - pending_line > 2) { pending = "" } if (is_fn && (a_timeout != "" || a_retry != "" || a_skip != "")) { # Tab is an IFS whitespace character, so `read` collapses a run of them # and an empty interior field would shift every later one. Absent # values therefore travel as a sentinel; the reason is last and may be # empty. printf "@@annot@@\t%s\t%s\t%s\t%s\t%s\n", fn, (a_timeout == "" ? "@@none@@" : a_timeout), (a_retry == "" ? "@@none@@" : a_retry), (a_skip == "" ? "0" : a_skip), a_reason } # A blank or code line ends the block for the next definition, whether or # not this line was one. a_timeout = "" a_retry = "" a_skip = "" a_reason = "" } END { printf "@@no_parallel@@\t%d\n", no_parallel } ' "$script" 2>/dev/null) } 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 } # src/helper/tags.sh function bashunit::helper::build_tags_map() { local script=$1 if [ ! -f "$script" ] && [ -n "${BASHUNIT_WORKING_DIR:-}" ]; then script="$BASHUNIT_WORKING_DIR/$script" fi if [ ! -f "$script" ]; then _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 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 ' # An uninitialised awk variable used as a subscript is the empty string, # not 0, so the first function would land in order[""] and be unreachable # from the numeric loop in END. BEGIN { n = 0 } # File-level tags: `# @tags a b` applies to every test in the file. Checked # before the singular rule and before the generic comment rule, and space # separated because it is a list rather than one tag per line. /^[[:space:]]*#[[:space:]]*@tags[[:space:]]/ { t = $0 sub(/^[[:space:]]*#[[:space:]]*@tags[[:space:]]+/, "", t) sub(/[[:space:]]+$/, "", t) gsub(/[[:space:]]+/, ",", t) if (t != "") { filetags = (filetags == "" ? t : filetags "," t) } next } /^[[: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) # Buffered rather than printed here so a `# @tags` line placed below the # functions still applies to them (single pass, order preserved). order[n] = fn own[n] = tags n++ tags = "" next } { tags = "" } END { for (i = 0; i < n; i++) { combined = own[i] if (filetags != "") { combined = (combined == "" ? filetags : combined "," filetags) } if (combined == "") { continue } # Function tags come first (nearest-first, as before); a tag carried at # both levels is emitted once. count = split(combined, parts, ",") out = "" delete seen for (j = 1; j <= count; j++) { if (parts[j] == "" || (parts[j] in seen)) { continue } seen[parts[j]] = 1 out = (out == "" ? parts[j] : out "," parts[j]) } if (out != "") { printf "%s\t%s\n", order[i], out } } } ' "$script" 2>/dev/null) } 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="" } function bashunit::helper::_tags_contain() { local fn_tags="$1" local needle="$2" local IFS=',' local tag for tag in $fn_tags; do if [ "$tag" = "$needle" ]; then return 0 fi done return 1 } function bashunit::helper::tag_expression_matches() { local fn_tags="$1" local rest="$2" local term negate more=true while [ "$more" = true ]; do case "$rest" in *"&&"*) term="${rest%%&&*}" rest="${rest#*&&}" ;; *) term="$rest" rest="" more=false ;; esac term="${term#"${term%%[![:space:]]*}"}" term="${term%"${term##*[![:space:]]}"}" negate=false case "$term" in '!'*) negate=true term="${term#!}" term="${term#"${term%%[![:space:]]*}"}" ;; esac if [ -z "$term" ]; then return 1 fi if bashunit::helper::_tags_contain "$fn_tags" "$term"; then if [ "$negate" = true ]; then return 1 fi elif [ "$negate" = false ]; then return 1 fi done return 0 } function bashunit::helper::function_matches_tags() { local fn_tags="$1" local include_tags="$2" local exclude_tags="$3" 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 if [ -n "$include_tags" ]; then local IFS=',' local expression for expression in $include_tags; do if bashunit::helper::tag_expression_matches "$fn_tags" "$expression"; then return 0 fi done return 1 fi return 0 } # src/helper/discovery.sh function bashunit::helper::check_duplicate_functions() { local script="$1" if [ ! -f "$script" ] && [ -n "${BASHUNIT_WORKING_DIR:-}" ]; then script="$BASHUNIT_WORKING_DIR/$script" fi 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 } # Recorded for every occurrence, the first included: the report needs # both definitions to be worth anything. if (lines[name] == "") { lines[name] = FNR } else { lines[name] = lines[name] ", " FNR } 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] "\t" lines[names[i]] } } ' "$script") if [ -n "$duplicates" ]; then local names="" local detail="" local dup_name dup_lines while IFS="$(printf '\t')" read -r dup_name dup_lines; do if [ -z "$dup_name" ]; then continue fi names="$names$dup_name " detail="$detail$dup_name (lines $dup_lines) " done <&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" } # src/cli/watch.sh 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 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 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 "$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" } 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 \ --recursive \ --include='.*\.sh$' \ --exclude='.*' \ --one-event \ "$path" 2>/dev/null ;; esac } # src/cli/doc.sh 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. Assertions are called **unprefixed** — `assert_same`, not `bashunit::assert_same`. Every other helper does take the `bashunit::` prefix; see [Globals](/globals). Run `bashunit doc` to print this catalogue in your terminal, or `bashunit doc ` to narrow it (`bashunit doc json`). Any assertion here also runs from the shell without a test file: `bashunit assert contains "world" "hello world"`. See [Standalone](/standalone). `assert_same`, `assert_equals`, `assert_not_same`, `assert_not_equals` and the numeric comparisons take an optional last argument used as the failure label: `assert_same "1" "2" "my custom label"` reports `✗ Failed: my custom label`. The string assertions whose signature ends in `...` take a variadic value: every argument after the first is joined with **newlines** and compared as one value. They accept no trailing label override, so `assert_contains "zzz" "abc" "my label"` searches `abc\nmy label` instead of relabelling the failure. ## Quick reference | Group | Assertions | |-------|------------| | **Booleans and equality** | [assert_true](#assert-true) · [assert_false](#assert-false) · [assert_same](#assert-same) · [assert_not_same](#assert-not-same) · [assert_equals](#assert-equals) · [assert_not_equals](#assert-not-equals) | | **Strings** | [assert_contains](#assert-contains) · [assert_not_contains](#assert-not-contains) · [assert_contains_ignore_case](#assert-contains-ignore-case) · [assert_matches](#assert-matches) · [assert_not_matches](#assert-not-matches) · [assert_string_starts_with](#assert-string-starts-with) · [assert_string_not_starts_with](#assert-string-not-starts-with) · [assert_string_ends_with](#assert-string-ends-with) · [assert_string_not_ends_with](#assert-string-not-ends-with) · [assert_string_matches_format](#assert-string-matches-format) · [assert_string_not_matches_format](#assert-string-not-matches-format) · [assert_empty](#assert-empty) · [assert_not_empty](#assert-not-empty) · [assert_line_count](#assert-line-count) | | **Numbers** | [assert_less_than](#assert-less-than) · [assert_less_or_equal_than](#assert-less-or-equal-than) · [assert_greater_than](#assert-greater-than) · [assert_greater_or_equal_than](#assert-greater-or-equal-than) · [assert_between](#assert-between) · [assert_not_between](#assert-not-between) · [assert_within_delta](#assert-within-delta) | | **Dates** | [assert_date_equals](#assert-date-equals) · [assert_date_before](#assert-date-before) · [assert_date_after](#assert-date-after) · [assert_date_within_range](#assert-date-within-range) · [assert_date_within_delta](#assert-date-within-delta) | | **Exit codes and commands** | [assert_exit_code](#assert-exit-code) · [assert_successful_code](#assert-successful-code) · [assert_unsuccessful_code](#assert-unsuccessful-code) · [assert_general_error](#assert-general-error) · [assert_command_available](#assert-command-available) · [assert_command_not_found](#assert-command-not-found) · [assert_exec](#assert-exec) | | **Files** | [assert_file_exists](#assert-file-exists) · [assert_file_not_exists](#assert-file-not-exists) · [assert_file_contains](#assert-file-contains) · [assert_file_not_contains](#assert-file-not-contains) · [assert_is_file](#assert-is-file) · [assert_is_file_empty](#assert-is-file-empty) · [assert_is_file_not_empty](#assert-is-file-not-empty) · [assert_is_file_readable](#assert-is-file-readable) · [assert_is_file_not_readable](#assert-is-file-not-readable) · [assert_is_file_writable](#assert-is-file-writable) · [assert_is_file_not_writable](#assert-is-file-not-writable) · [assert_is_file_executable](#assert-is-file-executable) · [assert_is_file_not_executable](#assert-is-file-not-executable) · [assert_is_symlink](#assert-is-symlink) · [assert_is_not_symlink](#assert-is-not-symlink) · [assert_symlink_to](#assert-symlink-to) · [assert_file_permissions](#assert-file-permissions) · [assert_files_equals](#assert-files-equals) · [assert_files_not_equals](#assert-files-not-equals) | | **Directories** | [assert_directory_exists](#assert-directory-exists) · [assert_directory_not_exists](#assert-directory-not-exists) · [assert_is_directory](#assert-is-directory) · [assert_is_directory_empty](#assert-is-directory-empty) · [assert_is_directory_not_empty](#assert-is-directory-not-empty) · [assert_is_directory_readable](#assert-is-directory-readable) · [assert_is_directory_not_readable](#assert-is-directory-not-readable) · [assert_is_directory_writable](#assert-is-directory-writable) · [assert_is_directory_not_writable](#assert-is-directory-not-writable) | | **Arrays** | [assert_arrays_equal](#assert-arrays-equal) · [assert_array_contains](#assert-array-contains) · [assert_array_not_contains](#assert-array-not-contains) · [assert_array_length](#assert-array-length) | | **JSON** | [assert_json_equals](#assert-json-equals) · [assert_json_contains](#assert-json-contains) · [assert_json_key_exists](#assert-json-key-exists) · [assert_json_key_not_exists](#assert-json-key-not-exists) · [assert_json_length](#assert-json-length) | | **Duration** | [assert_duration](#assert-duration) · [assert_duration_less_than](#assert-duration-less-than) · [assert_duration_greater_than](#assert-duration-greater-than) | | **Snapshots** | [assert_match_snapshot](#assert-match-snapshot) · [assert_match_named_snapshot](#assert-match-named-snapshot) · [assert_match_snapshot_ignore_colors](#assert-match-snapshot-ignore-colors) · [assert_match_named_snapshot_ignore_colors](#assert-match-named-snapshot-ignore-colors) | | **Spies** | [assert_have_been_called](#assert-have-been-called) · [assert_not_called](#assert-not-called) · [assert_have_been_called_with](#assert-have-been-called-with) · [assert_have_been_called_with_any](#assert-have-been-called-with-any) · [assert_have_been_called_with_args](#assert-have-been-called-with-args) · [assert_have_been_called_nth_with](#assert-have-been-called-nth-with) · [assert_have_been_called_times](#assert-have-been-called-times) | | **Assertions** | [assert_assertion_passes](#assert-assertion-passes) · [assert_assertion_fails](#assert-assertion-fails) · [assert_assertion_fails_with](#assert-assertion-fails-with) | | **Manual failure** | [bashunit::fail](#bashunit-fail) | ## assert_true > `assert_true bool|function|command [args...]` Pass a command with its arguments as separate arguments: ```bash assert_true test -d /tmp assert_true grep -q foo ./file assert_true my_function ``` Arguments are passed through untouched, so a value containing a space survives. A **single** argument keeps its older meaning: it is run as one command word, so `assert_true "test -d /tmp"` looks for a command with that whole name and fails with `unknown command`. Quote the whole thing only with an `eval` prefix — `assert_true "eval test -d /tmp"` — or, better, drop the quotes and use the form above. A purpose-built assertion is usually clearer still — `assert_directory_exists` rather than a hand-rolled `test -d`. Reports an error **unless** the argument results in a truthy value: `true` or `0`, or a command or function that exits `0`. - [assert_false](#assert-false) is the inverse of this assertion and takes the same arguments. ::: 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 [args...]` Reports an error **unless** the argument results in a falsy value: `false` or `1`, or a command or function that exits non-zero. - [assert_true](#assert-true) is the inverse of this assertion and takes the same arguments. ::: 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. Those are *characters*, not escape sequences. A backslash followed by `t` is two characters and stays two characters, so comparing it against a real tab fails; only the tab itself is stripped. - [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 "foo" $'\e[31mbar' } ``` ::: ## 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`. `pattern` is an ERE evaluated by `grep -E`. If it does not match as written, the value is retried with every newline replaced by a space, so a single-line pattern can match across lines: `assert_matches 'one two'` matches the two-line value `one\ntwo`. - [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" } ``` ::: ::: tip Cost in a hot loop This assertion runs `grep -E` in a subprocess for every call, so it is far more expensive than a string comparison: measured here, 500 `assert_matches` take ~1.25 s against ~130 ms for 2000 `assert_same` — about **38x per call**. That subprocess is deliberate. Bash 3.2 changed whether a quoted right-hand side of `[[ =~ ]]` is a regex or a literal, so at bashunit's Bash 3.0 floor the same pattern would match differently across supported versions. It is irrelevant for ordinary suites. If you are asserting inside a large loop and the pattern is a fixed substring or a glob, prefer [assert_contains](#assert-contains). ::: ## 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 characters other than a space (tabs and newlines match) | | `%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. A literal `\n` (backslash followed by `n`) counts as a line break too, so `assert_line_count 2 'one\ntwo'` passes even though the value holds no real newline. ::: 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_or_equal_than](#assert-greater-or-equal-than) is the counterpart 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_between > `assert_between "min" "max" "actual"` Reports an error if `actual` is outside the inclusive numeric range from `min` to `max`. Integers, decimals, and negative values are supported. `min` must not be greater than `max`. A non-numeric argument, or `min` greater than `max`, is a **usage error**: the assertion returns 2 and the test is reported as an Error rather than a failure, with a message such as `assert_between expects min <= max, got '500' and '100'`. - [assert_not_between](#assert-not-between) is the exact negation and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_between "100" "500" "275" assert_between "0.1" "0.3" "0.2" } function test_failure() { assert_between "100" "500" "750" } ``` ::: ## assert_not_between > `assert_not_between "min" "max" "actual"` Reports an error if `actual` is inside the inclusive numeric range from `min` to `max`. Integers, decimals, and negative values are supported. `min` must not be greater than `max`. A non-numeric argument, or `min` greater than `max`, is a **usage error**: the assertion returns 2 and the test is reported as an Error rather than a failure, with a message such as `assert_between expects min <= max, got '500' and '100'`. - [assert_between](#assert-between) is the exact negation and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_not_between "400" "499" "200" } function test_failure() { assert_not_between "400" "499" "404" } ``` ::: ## 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. The bound is inclusive, so `|actual - expected| == delta` passes. An operand that is not a number, such as `1.2.3` or `5-3`, fails the assertion with `to all be numeric` instead of being evaluated as an expression. A leading `+` is accepted. ::: 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). Anything else, including an empty string, fails the assertion with `Expected '' to be 'a valid date'` rather than being coerced to `0`. This applies to all five date assertions. ::: 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`. `delta` is required: omitting it produces a shell error reported as an Error, not an assertion failure. 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. Unrecognised arguments are silently dropped, so a mistyped flag such as `--stdout-contain` checks nothing and the assertion passes on exit status alone. Extra words after the command are dropped too: put arguments inside the command string, `assert_exec "echo hello" --stdout "hello"`. ::: 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 found in `haystack`. `needle` is matched as a **substring of the array joined with spaces**, not element by element, so `assert_array_contains "oob" foobar baz` and `assert_array_contains "foo bar" foo bar baz` both pass. Use [assert_arrays_equal](#assert-arrays-equal) when you need exact element comparison. - [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 } ``` ::: ::: warning Under `set -e`, pass the code as the third argument A non-zero exit aborts the test before the assertion runs, so the code has to be captured first — and capturing it sets `$?` to zero: ```bash local code=0 some_command || code=$? assert_successful_code # reads $? — the assignment's 0 assert_successful_code "$code" # the first argument is ignored, same result assert_successful_code "" "" "$code" # correct: read from the third argument ``` `assert_exit_code` takes a captured code the same way, but its first argument is the *expected* code: `assert_exit_code 7 "" "$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 } ``` ::: ::: warning Under `set -e`, pass the code as the third argument A non-zero exit aborts the test before the assertion runs, so the code has to be captured first — and capturing it sets `$?` to zero: ```bash local code=0 some_command || code=$? assert_unsuccessful_code # reads $? — the assignment's 0 assert_unsuccessful_code "$code" # the first argument is ignored, same result assert_unsuccessful_code "" "" "$code" # correct: read from the third argument ``` `assert_exit_code` takes a captured code the same way, but its first argument is the *expected* code: `assert_exit_code 7 "" "$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 } ``` ::: ::: warning Under `set -e`, pass the code as the third argument A non-zero exit aborts the test before the assertion runs, so the code has to be captured first — and capturing it sets `$?` to zero: ```bash local code=0 some_command || code=$? assert_general_error # reads $? — the assignment's 0 assert_general_error "$code" # the first argument is ignored, same result assert_general_error "" "" "$code" # correct: read from the third argument ``` `assert_exit_code` takes a captured code the same way, but its first argument is the *expected* code: `assert_exit_code 7 "" "$code"`. ::: ## assert_command_available > `assert_command_available "command"` Reports an error if `command` is not available. Availability uses the same `command -v` check as the [bashunit::is_command_available](/globals#bashunit-is-command-available) helper, so external commands, shell builtins and shell functions are supported. The command is only resolved; it is not executed. ::: code-group ```bash [Example] function test_dependencies_are_installed() { assert_command_available bash assert_command_available jq } function test_shell_function_is_available() { function project_build() { make build } assert_command_available project_build } ``` ::: ## 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 } ``` ::: ::: warning Under `set -e`, pass the code as the third argument A non-zero exit aborts the test before the assertion runs, so the code has to be captured first — and capturing it sets `$?` to zero: ```bash local code=0 some_command || code=$? assert_command_not_found # reads $? — the assignment's 0 assert_command_not_found "$code" # the first argument is ignored, same result assert_command_not_found "" "" "$code" # correct: read from the third argument ``` `assert_exit_code` takes a captured code the same way, but its first argument is the *expected* code: `assert_exit_code 7 "" "$code"`. ::: ## 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 contain the search string. `search` is matched **literally** (`grep -F`); regex metacharacters have no special meaning. - [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_is_symlink > `assert_is_symlink "path"` Reports an error if `path` is not a symbolic link. Every other filesystem assertion follows the link — `assert_is_file` and `assert_file_exists` report on the *target*, so a link and the file it points at look identical, and a dangling link reads as "does not exist". This is the assertion that tells them apart, and it passes for a link whose target is gone. ::: code-group ```bash [Example] function test_success() { ln -s /etc/hosts ./hosts_link assert_is_symlink "./hosts_link" } ``` ::: ## assert_is_not_symlink > `assert_is_not_symlink "path"` Reports an error if `path` is a symbolic link. ## assert_symlink_to > `assert_symlink_to "expected_target" "path"` Reports an error if `path` is not a symbolic link, or if it points somewhere other than `expected_target`. The target is compared **as written**, via `readlink`, not fully resolved: that is what the test author wrote, and `readlink -f` is GNU-only. A relative link therefore compares as the relative string it is. ::: code-group ```bash [Example] function test_success() { ln -s ./releases/42 ./current assert_symlink_to "./releases/42" "./current" } ``` ::: ## 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_is_file_not_empty > `assert_is_file_not_empty "file"` Reports an error if `file` is empty. A file holding a single newline counts as not empty. ::: code-group ```bash [Example] function test_success() { generate_report > report.txt assert_is_file_not_empty "report.txt" } ``` ::: ## assert_is_file_readable > `assert_is_file_readable "file"` Reports an error if `file` is not readable. The failure says which of the three things went wrong: the path does not exist, it exists but is not a file, or it is a file the current user cannot read. The same applies to every assertion below. ::: code-group ```bash [Example] function test_success() { assert_is_file_readable "/etc/hosts" } ``` ```[Output] ✗ Failed: Success Expected '/tmp/nope' to be readable but does not exist ``` ::: ## assert_is_file_not_readable > `assert_is_file_not_readable "file"` Reports an error if `file` is readable. ::: code-group ```bash [Example] function test_success() { chmod 000 "$secret" assert_is_file_not_readable "$secret" } ``` ::: ::: warning Running as root bypasses the permission bits, so this assertion passes for nothing under `sudo` or in a root container. Skip such a test there: `bashunit::skip_if "[ \"$(id -u)\" -eq 0 ]" "root reads anything"`. ::: ## assert_is_file_writable > `assert_is_file_writable "file"` Reports an error if `file` is not writable. ::: code-group ```bash [Example] function test_success() { assert_is_file_writable "$log_file" } ``` ::: ## assert_is_file_not_writable > `assert_is_file_not_writable "file"` Reports an error if `file` is writable. ::: code-group ```bash [Example] function test_success() { chmod 444 "$config" assert_is_file_not_writable "$config" } ``` ::: ## assert_is_file_executable > `assert_is_file_executable "file"` Reports an error if `file` is not executable — the thing a shell project most often wants to assert about a file it generated. ::: code-group ```bash [Example] function test_success() { ./build.sh assert_is_file_executable "bin/tool" } ``` ```[Output] ✗ Failed: Success Expected 'bin/tool' to be executable but is not executable ``` ::: ## assert_is_file_not_executable > `assert_is_file_not_executable "file"` Reports an error if `file` is executable. ::: code-group ```bash [Example] function test_success() { assert_is_file_not_executable "README.md" } ``` ::: ## 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 directory="$(bashunit::temp_dir)" 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 directory="$(bashunit::temp_dir)" 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 directory="$(bashunit::temp_dir)" 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_equals > `assert_not_equals "expected" "actual"` Reports an error if the two variables `expected` and `actual` are equal ignoring the special chars like ANSI Escape Sequences (colors) and other special chars like tabs and new lines. - [assert_equals](#assert-equals) is the inverse of this assertion and takes the same arguments. ::: code-group ```bash [Example] function test_success() { assert_not_equals "foo" "bar" } function test_failure() { assert_not_equals "foo" "foo" } ``` ::: ## 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 found in `haystack`. `needle` is matched as a **substring of the array joined with spaces**, not element by element, so `assert_array_not_contains "foo bar" foo bar` fails even though no single element is `foo bar`. - [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. `search` is matched as a **basic regular expression** (`grep`), unlike [assert_file_contains](#assert-file-contains) which matches literally, so `assert_file_not_contains file 'a.c'` fails on a file containing `abc`. - [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 directory="$(bashunit::temp_dir)" 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 directory="$(bashunit::temp_dir)" 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 directory="$(bashunit::temp_dir)" 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` have the same contents. - [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. A key whose value is `null` or `false` is reported as missing, because `jq -e` treats both as absent. `0` and `""` are fine. To assert a false or null value, use `assert_json_contains ".flag" "false" "$json"`. ::: 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. A key whose value is `null` or `false` is reported as missing, the same guard [assert_json_key_exists](#assert-json-key-exists) uses. ::: 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_key_not_exists > `assert_json_key_not_exists "key" "json"` Reports an error if `key` exists 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. A key with a `null` value still exists and makes this assertion fail. Invalid JSON also fails the assertion. ::: code-group ```bash [Example] function test_success() { assert_json_key_not_exists ".user.password" '{"user":{"name":"bashunit"}}' } function test_failure() { assert_json_key_not_exists ".user.password" '{"user":{"password":null}}' } ``` ::: ## 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_json_length > `assert_json_length "expected" "key" "json"` Reports an error if the array, object, or string at `key` does not have the expected length. Arrays count elements, objects count key-value pairs, and strings count Unicode codepoints, following `jq`'s `length` behavior. A missing path, unsupported value type, invalid JSON, or non-numeric expected length fails instead of being compared as empty or zero. Requires `jq` to be installed; if missing the test is skipped. ::: code-group ```bash [Example] function test_success() { assert_json_length 3 ".items" '{"items":[1,2,3]}' assert_json_length 2 ".metadata" '{"metadata":{"page":1,"total":3}}' } function test_failure() { assert_json_length 2 ".items" '{"items":[1,2,3]}' assert_json_length 0 ".missing" '{"items":[]}' } ``` ::: ## 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. Requires `awk`, plus `bc` or `awk` for the arithmetic. Unlike the JSON assertions, the duration assertions do **not** skip when the tool is missing: the test is reported as an Error. ::: 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 } ``` ::: ## assert_match_snapshot > `assert_match_snapshot "actual" ["snapshot_file"]` Reports an error if `actual` differs from the stored snapshot. On the first run no snapshot exists, so one is written from `actual` and the assertion passes — review and commit that file. Pass `snapshot_file` to share one snapshot between tests; by default each test gets its own, named after the test function. ::: warning A `@data_provider` test shares one snapshot The filename comes from the test **function**, and a provider runs that function once per value — so every value compares against the same file. The first value to run creates it; the rest fail with `Expected to match the snapshot` even though nothing is wrong with them: ```bash function provide_values() { echo "alpha"; echo "beta"; } # @data_provider provide_values function test_shared() { assert_match_snapshot "value is $1" # one file for both values } # @data_provider provide_values function test_per_value() { assert_match_named_snapshot "$1" "value is $1" # one file per value } ``` Use [assert_match_named_snapshot](#assert-match-named-snapshot) with the value as the name to give each data set its own. ::: See [Snapshots](/snapshots) for the full workflow, including how to update a snapshot after an intentional change. ::: code-group ```bash [Example] function test_success() { assert_match_snapshot "$(./bin/render --help)" } function test_failure() { assert_match_snapshot "output that no longer matches the stored snapshot" } ``` ::: ## assert_match_snapshot_ignore_colors > `assert_match_snapshot_ignore_colors "actual" ["snapshot_file"]` Same as [assert_match_snapshot](#assert-match-snapshot), but strips ANSI escape sequences from `actual` before comparing. Use it for commands whose colouring depends on the terminal. ::: code-group ```bash [Example] function test_success() { assert_match_snapshot_ignore_colors "$(./bin/render --help)" } function test_failure() { assert_match_snapshot_ignore_colors "output that no longer matches the stored snapshot" } ``` ::: ## assert_match_named_snapshot > `assert_match_named_snapshot "name" "actual"` Matches `actual` against a snapshot whose filename includes `name`. Use it for multiple independent snapshots in one test without constructing file paths yourself. Names are normalized so they cannot escape the test's `snapshots/` directory. ::: code-group ```bash [Example] function test_render_modes() { assert_match_named_snapshot "compact" "$(./bin/render --compact)" assert_match_named_snapshot "verbose" "$(./bin/render --verbose)" } ``` ::: ## assert_match_named_snapshot_ignore_colors > `assert_match_named_snapshot_ignore_colors "name" "actual"` Named version of [assert_match_snapshot_ignore_colors](#assert-match-snapshot-ignore-colors). ANSI escape sequences are stripped from `actual` before it is stored or compared. ::: code-group ```bash [Example] function test_colored_render_modes() { assert_match_named_snapshot_ignore_colors "compact" "$(./bin/render --compact)" assert_match_named_snapshot_ignore_colors "verbose" "$(./bin/render --verbose)" } ``` ::: ## assert_have_been_called > `assert_have_been_called "command"` Reports an error if the spied `command` was never called. Requires `bashunit::spy command` first — see [Test doubles](/test-doubles). Every `assert_have_been_called*` and `assert_not_called` fails with `was never registered as a spy` when the name was never passed to `bashunit::spy`, including `assert_not_called`, which does **not** pass for an unspied name. Spies are cleared between tests, so spy inside the test that asserts on it. ::: code-group ```bash [Example] function test_success() { bashunit::spy send_email notify_user assert_have_been_called send_email } function test_failure() { bashunit::spy send_email assert_have_been_called send_email } ``` ::: ## assert_have_been_called_with > `assert_have_been_called_with "command" "expected_args" [nth]` Reports an error if the spied `command` was not called with `expected_args`. Checks the **last** call unless a trailing all-digits `nth` selects a specific one; the failure names the call it compared. Because `nth` is detected as a trailing all-digits argument, an expectation whose last argument is a number is read as the selector: `assert_have_been_called_with git commit -m 5` compares `commit -m` against call 5. Pass the expectation as one quoted string, `assert_have_been_called_with git "commit -m 5"`, or use [assert_have_been_called_with_args](#assert-have-been-called-with-args), which has no `nth`. To match any call, use [assert_have_been_called_with_any](#assert-have-been-called-with-any). Note the argument order: the spy comes first here, but *second* in [assert_have_been_called_times](#assert-have-been-called-times). ::: code-group ```bash [Example] function test_success() { bashunit::spy send_email notify_user "a@b.c" assert_have_been_called_with send_email "--to a@b.c" } function test_failure() { bashunit::spy send_email notify_user "a@b.c" assert_have_been_called_with send_email "--to nobody@example.com" } ``` ::: ## assert_have_been_called_with_any > `assert_have_been_called_with_any "command" "expected_args"` Reports an error if no recorded call to the spied `command` received `expected_args`. Where [assert_have_been_called_with](#assert-have-been-called-with) compares a single call — the last one, or the one at `nth` — this one scans them all, so the assertion does not break when an unrelated call is added after it. ::: code-group ```bash [Example] function test_success() { bashunit::spy send_email notify_all "a@b.c" "d@e.f" assert_have_been_called_with_any send_email "--to a@b.c" } function test_failure() { bashunit::spy send_email notify_all "a@b.c" "d@e.f" assert_have_been_called_with_any send_email "--to nobody@example.com" } ``` ::: ## assert_have_been_called_with_args > `assert_have_been_called_with_args "command" "expected_arg"...` Reports an error if the **last** call to the spied `command` did not receive exactly these arguments. Unlike [assert_have_been_called_with](#assert-have-been-called-with), the arguments are compared one by one, so `cmd "a b"` does not match `cmd a b`. Use it whenever an argument may contain spaces, such as a path. There is no `nth` parameter: a trailing number would be indistinguishable from a numeric argument. ::: code-group ```bash [Example] function test_success() { bashunit::spy touch create_report "/tmp/my reports" assert_have_been_called_with_args touch "/tmp/my reports/out.txt" } function test_failure() { bashunit::spy touch create_report "/tmp/my reports" assert_have_been_called_with_args touch "/tmp/my" "reports/out.txt" } ``` ::: ## assert_have_been_called_times > `assert_have_been_called_times "expected_count" "command"` Reports an error if the spied `command` was not called exactly `expected_count` times. The count comes **first**, the spy second. ::: code-group ```bash [Example] function test_success() { bashunit::spy send_email notify_all "a@b.c" "d@e.f" assert_have_been_called_times 2 send_email } function test_failure() { bashunit::spy send_email notify_all "a@b.c" "d@e.f" assert_have_been_called_times 1 send_email } ``` ::: ## assert_have_been_called_nth_with > `assert_have_been_called_nth_with "nth" "command" "expected_args"` Reports an error if call number `nth` of the spied `command` did not receive `expected_args`. Calls are numbered from 1. ::: code-group ```bash [Example] function test_success() { bashunit::spy send_email notify_all "a@b.c" "d@e.f" assert_have_been_called_nth_with 1 send_email "--to a@b.c" } function test_failure() { bashunit::spy send_email notify_all "a@b.c" "d@e.f" assert_have_been_called_nth_with 1 send_email "--to d@e.f" } ``` ::: ## assert_not_called > `assert_not_called "command"` Reports an error if the spied `command` was called at all. The inverse of [assert_have_been_called](#assert-have-been-called). ::: code-group ```bash [Example] function test_success() { bashunit::spy send_email notify_user --dry-run assert_not_called send_email } function test_failure() { bashunit::spy send_email notify_user assert_not_called send_email } ``` ::: ## 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 } ``` ::: ## assert_assertion_passes > `assert_assertion_passes [args...]` Reports an error unless the given assertion reports a success. Use it to test your own [custom assertions](/custom-asserts). The inner assertion runs isolated: its verdict is never added to the run totals, its failure output never reaches the console, and it cannot trip the stop-on-failure guard for the rest of your test. Exactly one assertion is counted — this one. ::: code-group ```bash [Example] function test_success() { assert_assertion_passes assert_positive_number 1 } function test_failure() { assert_assertion_passes assert_positive_number 0 } ``` ::: ## assert_assertion_fails > `assert_assertion_fails [args...]` Reports an error unless the given assertion reports a failure. An assertion that counts nothing at all also fails this check. The message the inner assertion produced is left in `$_BASHUNIT_ASSERT_INNER_OUTPUT_OUT`, colour-stripped and flattened to one line, so you can also assert on what it did *not* say. ::: code-group ```bash [Example] function test_success() { assert_assertion_fails assert_positive_number 0 } function test_failure() { assert_assertion_fails assert_positive_number 1 } ``` ::: ## assert_assertion_fails_with > `assert_assertion_fails_with [args...]` Reports an error unless the given assertion fails **and** its failure message contains `expected_message`. This is how a custom assertion's output contract gets tested without rebuilding the expected string from `console_results`. ::: code-group ```bash [Example] function test_success() { assert_assertion_fails_with "positive number" assert_positive_number 0 } function test_failure() { assert_assertion_fails_with "negative number" assert_positive_number 0 } ``` ::: ## 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 - [Standalone](/standalone) — run these assertions straight from the command line __BASHUNIT_DOCS_EOF__ } 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" } } ' } _BASHUNIT_DOC_CUSTOM_FNS_OUT="" function bashunit::doc::custom_fns_to_slot() { local known="$1" local -a found found=() local count=0 local fn for fn in $(compgen -A function assert_ 2>/dev/null); do case " $known " in *" $fn "*) continue ;; esac local i=$count while [ "$i" -gt 0 ] && [ "${found[$((i - 1))]}" \> "$fn" ]; do found[i]=${found[$((i - 1))]} i=$((i - 1)) done found[i]=$fn count=$((count + 1)) done local out="" local j=0 while [ "$j" -lt "$count" ]; do out="$out${found[j]}"$'\n' j=$((j + 1)) done _BASHUNIT_DOC_CUSTOM_FNS_OUT="${out%$'\n'}" } function bashunit::doc::print_fn_comment() { local fn="$1" local info info="$( shopt -s extdebug declare -F "$fn" )" local rest="${info#* }" local def_line="${rest%% *}" local file="${rest#* }" case "$def_line" in '' | *[!0-9]*) return 0 ;; esac [ -n "$file" ] && [ -f "$file" ] || return 0 local -a lines lines=() local count=0 local line while IFS= read -r line || [ -n "$line" ]; do lines[count]="$line" count=$((count + 1)) done <"$file" local first=$((def_line - 1)) local i=$((first - 1)) while [ "$i" -ge 0 ]; do case "${lines[i]:-}" in '#'*) i=$((i - 1)) ;; *) break ;; esac done local j=$((i + 1)) while [ "$j" -lt "$first" ]; do line="${lines[j]:-}" line="${line#\#}" line="${line#\#}" line="${line# }" printf '%s\n' "$line" j=$((j + 1)) done } function bashunit::doc::print_custom_asserts() { local filter="${1:-}" local printed=1 local fn for fn in $_BASHUNIT_DOC_CUSTOM_FNS_OUT; do if [ -n "$filter" ]; then case "$fn" in *"$filter"*) ;; *) continue ;; esac fi printf '## %s\n' "$fn" printf -- '--------------\n' bashunit::doc::print_fn_comment "$fn" printf '\n' printed=0 done return $printed } # src/cli/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 -Fxq "$env_line" "$env_file"; then echo "> $env_file already sets BASHUNIT_BOOTSTRAP" else 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" echo "> Updated $env_file" fi else echo "$env_line" >"$env_file" echo "> Created $env_file" fi echo "> bashunit initialized in $tests_dir" } # src/assert/index.sh # src/assert/core.sh function bashunit::assert::mark_failed() { if [ "${_BASHUNIT_ASSERT_ONCE_ACTIVE:-0}" -eq 1 ]; then if bashunit::assert::once_is_absorbing; then _BASHUNIT_ASSERT_ONCE_FAILED=1 return 0 fi fi bashunit::state::add_assertions_failed bashunit::state::mark_assertion_failed_in_test } function bashunit::assert::should_skip() { bashunit::env::is_stop_on_assertion_failure_enabled && ((_BASHUNIT_ASSERTION_FAILED_IN_TEST)) } function bashunit::assert::usage_error_detail() { local assertion=$1 local detail=$2 printf 'bashunit: assertion usage error: %s %s\n' "$assertion" "$detail" >&2 } function bashunit::assert::usage_error() { local assertion=$1 local required=$2 local signature=$3 local supplied=$4 printf 'bashunit: assertion usage error: %s expects %s arguments (%s), got %s\n' \ "$assertion" "$required" "$signature" "$supplied" >&2 } _BASHUNIT_ASSERT_LABEL_OUT="" function bashunit::assert::label_to_slot() { local custom_label="${1:-}" local fallback_depth="${2:-2}" if [ -n "$custom_label" ]; then _BASHUNIT_ASSERT_LABEL_OUT=$custom_label return fi bashunit::helper::find_test_function_name_to_slot "$fallback_depth" bashunit::helper::normalize_test_function_name_to_slot "$_BASHUNIT_HELPER_TESTFN_OUT" _BASHUNIT_ASSERT_LABEL_OUT=$_BASHUNIT_HELPER_NORMALIZED_OUT } function bashunit::assert::fail_with() { bashunit::assert::label_to_slot "${1:-}" 3 bashunit::assert::mark_failed bashunit::console_results::print_failed_test \ "$_BASHUNIT_ASSERT_LABEL_OUT" "${2-}" "${3-}" "${4-}" "${5-}" "${6-}" } _BASHUNIT_ASSERT_JOINED_OUT="" function bashunit::assert::join_to_slot() { local IFS=$'\n' local joined="$*" while [ "$joined" != "${joined%$'\n'}" ]; do joined="${joined%$'\n'}" done _BASHUNIT_ASSERT_JOINED_OUT=$joined } 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" } _BASHUNIT_ASSERT_BOOL_EXIT_OUT=0 function bashunit::assert::_run_bool_subject() { local exit_code=0 if [ $# -gt 1 ]; then "$@" >/dev/null 2>&1 || exit_code=$? else bashunit::run_command_or_eval "$1" || exit_code=$? fi _BASHUNIT_ASSERT_BOOL_EXIT_OUT=$exit_code } _BASHUNIT_ASSERT_EXIT_DESC_OUT="" function bashunit::assert::_describe_exit_code() { case "$1" in 127) _BASHUNIT_ASSERT_EXIT_DESC_OUT="unknown command: $2" ;; 126) _BASHUNIT_ASSERT_EXIT_DESC_OUT="not executable: $2" ;; *) _BASHUNIT_ASSERT_EXIT_DESC_OUT="exit code: $1" ;; esac } function assert_true() { bashunit::assert::should_skip && return 0 local actual="$1" if [ $# -eq 1 ]; then 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 fi bashunit::assert::_run_bool_subject "$@" local exit_code=$_BASHUNIT_ASSERT_BOOL_EXIT_OUT actual="$*" if [ "$exit_code" -ne 0 ]; then bashunit::assert::_describe_exit_code "$exit_code" "$actual" bashunit::handle_bool_assertion_failure \ "command or function with zero exit code" "$_BASHUNIT_ASSERT_EXIT_DESC_OUT" else bashunit::state::add_assertions_passed fi } function assert_false() { bashunit::assert::should_skip && return 0 local actual="$1" if [ $# -eq 1 ]; then 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 fi bashunit::assert::_run_bool_subject "$@" local exit_code=$_BASHUNIT_ASSERT_BOOL_EXIT_OUT actual="$*" case "$exit_code" in 0 | 126 | 127) bashunit::assert::_describe_exit_code "$exit_code" "$actual" bashunit::handle_bool_assertion_failure \ "command or function with non-zero exit code" "$_BASHUNIT_ASSERT_EXIT_DESC_OUT" ;; *) bashunit::state::add_assertions_passed ;; esac } function bashunit::run_command_or_eval() { local cmd="$1" case "$cmd" in eval\ * | eval) eval "${cmd#eval }" &>/dev/null ;; *[=[:space:]]* | "") "$cmd" &>/dev/null ;; *) 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 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local expected="$1" local actual="$2" local label_override="${3:-}" if [ "$expected" != "$actual" ]; then bashunit::assert::fail_with "${label_override:-}" "${expected}" "but got " "${actual}" return fi bashunit::state::add_assertions_passed } function assert_equals() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi 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::fail_with "${label_override:-}" "${expected_cleaned}" "but got " "${actual_cleaned}" return fi bashunit::state::add_assertions_passed } function assert_not_equals() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi 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::fail_with "${label_override:-}" "${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::fail_with "${label_override:-}" "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::fail_with "${label_override:-}" "to not be empty" "but got " "${expected}" return fi bashunit::state::add_assertions_passed } function assert_not_same() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local expected="$1" local actual="$2" local label_override="${3:-}" if [ "$expected" = "$actual" ]; then bashunit::assert::fail_with "${label_override:-}" "${expected}" "to not be" "${actual}" return fi bashunit::state::add_assertions_passed } function assert_contains() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") local label_override="" bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected"*) ;; *) bashunit::assert::fail_with "${label_override:-}" "${actual}" "to contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function bashunit::assert::_supports_nocasematch() { if [ "${BASH_VERSINFO[0]:-0}" -gt 3 ]; then return 0 fi [ "${BASH_VERSINFO[0]:-0}" -eq 3 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 1 ] } function assert_contains_ignore_case() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local expected="$1" local actual="$2" local label_override="${3:-}" if bashunit::assert::_supports_nocasematch; then local nocase_was_set=1 shopt -q nocasematch || nocase_was_set=0 shopt -s nocasematch local matched=1 case "$actual" in *"$expected"*) ;; *) matched=0 ;; esac [ "$nocase_was_set" -eq 1 ] || shopt -u nocasematch if [ "$matched" -eq 0 ]; then bashunit::assert::fail_with "${label_override:-}" "${actual}" "to contain" "${expected}" return fi bashunit::state::add_assertions_passed return fi 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::fail_with "${label_override:-}" "${actual}" "to contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_not_contains() { local label_override="" bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected"*) bashunit::assert::fail_with "${label_override:-}" "${actual}" "to not contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_matches() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "pattern, actual" "$#" return 2 fi local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT if [ "$(printf '%s' "$actual" | "$GREP" -cE "$expected" || true)" -eq 0 ]; then 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 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "pattern, actual" "$#" return 2 fi local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT if [ "$(printf '%s' "$actual" | "$GREP" -cE "$expected" || true)" -gt 0 ] || [ "$(printf '%s' "$actual" | tr '\n' ' ' | "$GREP" -cE "$expected" || true)" -gt 0 ]; then bashunit::assert::fail_with "${label_override:-}" "${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" local exit_code=0 eval "$cmd" <"$stdin_file" >"$stdout_file" 2>"$stderr_file" || exit_code=$? rm -f "$stdin_file" else local exit_code=0 eval "$cmd" >"$stdout_file" 2>"$stderr_file" || 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::fail_with "${label_override:-}" "$expected_desc" "but got " "$actual_desc" return fi bashunit::state::add_assertions_passed } function assert_exit_code() { local actual_exit_code=${3-"$?"} local label_override="" bashunit::assert::should_skip && return 0 local expected_exit_code="$1" if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then bashunit::assert::fail_with "${label_override:-}" "${actual_exit_code}" "to be" "${expected_exit_code}" return fi bashunit::state::add_assertions_passed } function assert_successful_code() { local actual_exit_code=${3-"$?"} local label_override="" bashunit::assert::should_skip && return 0 local expected_exit_code=0 if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then bashunit::assert::fail_with "${label_override:-}" \ "${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-"$?"} local label_override="" bashunit::assert::should_skip && return 0 if ! [ "$actual_exit_code" -ne 0 ]; then bashunit::assert::fail_with "${label_override:-}" "${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-"$?"} local label_override="" bashunit::assert::should_skip && return 0 local expected_exit_code=1 if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then bashunit::assert::fail_with "${label_override:-}" \ "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi bashunit::state::add_assertions_passed } function assert_command_available() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 1 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "command" "$#" return 2 fi local command="$1" local label_override="${2:-}" if ! bashunit::is_command_available "$command"; then bashunit::assert::fail_with "${label_override:-}" \ "${command}" "to be available but was" "not found" return fi bashunit::state::add_assertions_passed } function assert_command_not_found() { local actual_exit_code=${3-"$?"} local label_override="" bashunit::assert::should_skip && return 0 local expected_exit_code=127 if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then bashunit::assert::fail_with "${label_override:-}" \ "${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 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in "$expected"*) ;; *) bashunit::assert::fail_with "${label_override:-}" "${actual}" "to start with" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_string_not_starts_with() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local expected="$1" local actual="$2" local label_override="${3:-}" case "$actual" in "$expected"*) bashunit::assert::fail_with "${label_override:-}" "${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 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected") ;; *) bashunit::assert::fail_with "${label_override:-}" "${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 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected") bashunit::assert::fail_with "${label_override:-}" "${actual}" "to not end with" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_less_than() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local expected="$1" local actual="$2" local label_override="${3:-}" if ! [ "$actual" -lt "$expected" ]; then bashunit::assert::fail_with "${label_override:-}" "${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 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local expected="$1" local actual="$2" local label_override="${3:-}" if ! [ "$actual" -le "$expected" ]; then bashunit::assert::fail_with "${label_override:-}" "${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 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local expected="$1" local actual="$2" local label_override="${3:-}" if ! [ "$actual" -gt "$expected" ]; then bashunit::assert::fail_with "${label_override:-}" "${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 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi local expected="$1" local actual="$2" local label_override="${3:-}" if ! [ "$actual" -ge "$expected" ]; then bashunit::assert::fail_with "${label_override:-}" "${actual}" "to be greater or equal than" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_between() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 3 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "min, max, actual" "$#" return 2 fi local min="$1" local max="$2" local actual="$3" local label_override="${4:-}" if ! bashunit::assert::_validate_range_args "${FUNCNAME[0]}" "$min" "$max" "$actual"; then return 2 fi if ! bashunit::math::is_le "$min" "$actual"; then bashunit::assert::fail_with "$label_override" "$actual" "to be between" "$min and $max" \ "Violated lower bound" "$min" return fi if ! bashunit::math::is_le "$actual" "$max"; then bashunit::assert::fail_with "$label_override" "$actual" "to be between" "$min and $max" \ "Violated upper bound" "$max" return fi bashunit::state::add_assertions_passed } function assert_not_between() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 3 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "min, max, actual" "$#" return 2 fi local min="$1" local max="$2" local actual="$3" local label_override="${4:-}" if ! bashunit::assert::_validate_range_args "${FUNCNAME[0]}" "$min" "$max" "$actual"; then return 2 fi if bashunit::math::is_le "$min" "$actual" && bashunit::math::is_le "$actual" "$max"; then bashunit::assert::fail_with "$label_override" "$actual" "to not be between" "$min and $max" return fi bashunit::state::add_assertions_passed } function bashunit::assert::_is_numeric() { local value="$1" case "$value" in '' | *[!0-9.+-]*) return 1 ;; -*) value=${value#-} ;; +*) value=${value#+} ;; esac case "$value" in '' | '.' | *[+-]*) return 1 ;; *.*) local fraction=${value#*.} case "$fraction" in *.*) return 1 ;; esac ;; esac case "$value" in *[0-9]*) return 0 ;; esac return 1 } function bashunit::assert::_validate_range_args() { local assertion=$1 local min=$2 local max=$3 local actual=$4 if ! bashunit::assert::_is_numeric "$min" || ! bashunit::assert::_is_numeric "$max" || ! bashunit::assert::_is_numeric "$actual"; then bashunit::assert::usage_error_detail "$assertion" \ "expects numeric min, max, and actual values, got '$min', '$max', '$actual'" return 1 fi if ! bashunit::math::is_le "$min" "$max"; then bashunit::assert::usage_error_detail "$assertion" "expects min <= max, got '$min' and '$max'" return 1 fi } function assert_within_delta() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 3 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "expected, actual, delta" "$#" return 2 fi 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::fail_with "" "${expected} ${actual} ${delta}" \ "to all be numeric" "but got a non-numeric value" return fi expected=${expected#+} actual=${actual#+} delta=${delta#+} local scale expected_places actual_places delta_places bashunit::math::decimals_to_slot "$expected" expected_places=$_BASHUNIT_MATH_DECIMALS_OUT bashunit::math::decimals_to_slot "$actual" actual_places=$_BASHUNIT_MATH_DECIMALS_OUT bashunit::math::decimals_to_slot "$delta" delta_places=$_BASHUNIT_MATH_DECIMALS_OUT scale=$expected_places if [ "$actual_places" -gt "$scale" ]; then scale=$actual_places fi if [ "$delta_places" -gt "$scale" ]; then scale=$delta_places fi local padded_expected padded_actual padded_delta bashunit::math::pad_to_slot "$expected" "$scale" padded_expected=$_BASHUNIT_MATH_PADDED_OUT bashunit::math::pad_to_slot "$actual" "$scale" padded_actual=$_BASHUNIT_MATH_PADDED_OUT bashunit::math::pad_to_slot "$delta" "$scale" padded_delta=$_BASHUNIT_MATH_PADDED_OUT if bashunit::math::scale_pair_to_slots "$padded_expected" "$padded_actual"; then local scaled_diff=$((_BASHUNIT_MATH_SCALED_L_OUT - _BASHUNIT_MATH_SCALED_R_OUT)) if [ "$scaled_diff" -lt 0 ]; then scaled_diff=$((-scaled_diff)) fi if bashunit::math::scale_pair_to_slots "$padded_delta" "$padded_expected"; then if [ "$scaled_diff" -gt "$_BASHUNIT_MATH_SCALED_L_OUT" ]; then bashunit::assert::fail_with "" "${actual}" "to be within ${delta} of" "${expected}" return fi bashunit::state::add_assertions_passed return fi 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::fail_with "" "${actual}" "to be within ${delta} of" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_line_count() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi 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 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::fail_with "${label_override:-}" "${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 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "format, actual" "$#" return 2 fi 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::fail_with "${label_override:-}" "${actual}" "to match format" "${format}" return fi bashunit::state::add_assertions_passed } function assert_string_not_matches_format() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "format, actual" "$#" return 2 fi 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::fail_with "${label_override:-}" "${actual}" "to not match format" "${format}" return fi bashunit::state::add_assertions_passed } # src/assert/arrays.sh function assert_arrays_equal() { bashunit::assert::should_skip && return 0 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::fail_with "" "--" "but got " "missing array separator" return fi if [ "${#expected_values[@]}" -ne "${#actual_values[@]}" ]; then bashunit::assert::fail_with "" "${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::fail_with "" "${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" shift local -a actual actual=("$@") case "${actual[*]:-}" in *"$expected"*) ;; *) bashunit::assert::fail_with "" "${actual[*]}" "to contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } function assert_array_length() { bashunit::assert::should_skip && return 0 local expected="$1" shift local actual_length="$#" if [ "$expected" != "$actual_length" ]; then bashunit::assert::fail_with "" "$*" "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" shift local -a actual actual=("$@") case "${actual[*]:-}" in *"$expected"*) bashunit::assert::fail_with "" "${actual[*]}" "to not contain" "${expected}" return ;; esac bashunit::state::add_assertions_passed } # src/assert/assertions.sh _BASHUNIT_ASSERT_INNER_FAILED_OUT=0 _BASHUNIT_ASSERT_INNER_PASSED_OUT=0 _BASHUNIT_ASSERT_INNER_OUTPUT_OUT="" function bashunit::assert::_capture() { local before_passed=$_BASHUNIT_ASSERTIONS_PASSED local before_failed=$_BASHUNIT_ASSERTIONS_FAILED local before_guard=$_BASHUNIT_ASSERTION_FAILED_IN_TEST local before_output=$_BASHUNIT_TEST_OUTPUT local before_count=$_BASHUNIT_TOTAL_TESTS_COUNT local before_once_active=$_BASHUNIT_ASSERT_ONCE_ACTIVE local before_once_frame=$_BASHUNIT_ASSERT_ONCE_FRAME local before_once_abs=$_BASHUNIT_ASSERT_ONCE_ABS local before_once_label=$_BASHUNIT_ASSERT_ONCE_LABEL local before_once_actual=$_BASHUNIT_ASSERT_ONCE_ACTUAL local before_once_failed=$_BASHUNIT_ASSERT_ONCE_FAILED local before_once_in_expected=$_BASHUNIT_ASSERT_ONCE_IN_EXPECTED local before_once_in_condition=$_BASHUNIT_ASSERT_ONCE_IN_CONDITION local before_once_in_actual=$_BASHUNIT_ASSERT_ONCE_IN_ACTUAL bashunit::assert::once_reset _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 "$@" >/dev/null 2>&1 || true bashunit::assert::once_flush >/dev/null 2>&1 bashunit::str::strip_ansi_to_slot "${_BASHUNIT_TEST_OUTPUT#"$before_output"}" local output=$_BASHUNIT_STR_STRIPPED_OUT if [ "$_BASHUNIT_ASSERTIONS_FAILED" -gt "$before_failed" ]; then _BASHUNIT_ASSERT_INNER_FAILED_OUT=1 else _BASHUNIT_ASSERT_INNER_FAILED_OUT=0 fi if [ "$_BASHUNIT_ASSERTIONS_PASSED" -gt "$before_passed" ]; then _BASHUNIT_ASSERT_INNER_PASSED_OUT=1 else _BASHUNIT_ASSERT_INNER_PASSED_OUT=0 fi _BASHUNIT_ASSERT_INNER_OUTPUT_OUT=$output _BASHUNIT_ASSERTIONS_PASSED=$before_passed _BASHUNIT_ASSERTIONS_FAILED=$before_failed _BASHUNIT_ASSERTION_FAILED_IN_TEST=$before_guard _BASHUNIT_TEST_OUTPUT=$before_output _BASHUNIT_TOTAL_TESTS_COUNT=$before_count _BASHUNIT_ASSERT_ONCE_FRAME=$before_once_frame _BASHUNIT_ASSERT_ONCE_ABS=$before_once_abs _BASHUNIT_ASSERT_ONCE_LABEL=$before_once_label _BASHUNIT_ASSERT_ONCE_ACTUAL=$before_once_actual _BASHUNIT_ASSERT_ONCE_FAILED=$before_once_failed _BASHUNIT_ASSERT_ONCE_IN_EXPECTED=$before_once_in_expected _BASHUNIT_ASSERT_ONCE_IN_CONDITION=$before_once_in_condition _BASHUNIT_ASSERT_ONCE_IN_ACTUAL=$before_once_in_actual _BASHUNIT_ASSERT_ONCE_ACTIVE=$before_once_active } _BASHUNIT_ASSERT_INNER_OUTCOME_OUT="" function bashunit::assert::_inner_outcome_to_slot() { if [ "$_BASHUNIT_ASSERT_INNER_FAILED_OUT" -eq 1 ]; then _BASHUNIT_ASSERT_INNER_OUTCOME_OUT="a failing assertion" elif [ "$_BASHUNIT_ASSERT_INNER_PASSED_OUT" -eq 1 ]; then _BASHUNIT_ASSERT_INNER_OUTCOME_OUT="a passing assertion" else _BASHUNIT_ASSERT_INNER_OUTCOME_OUT="no assertion at all" fi } function assert_assertion_fails() { bashunit::assert::should_skip && return 0 bashunit::assert::_capture "$@" if [ "$_BASHUNIT_ASSERT_INNER_FAILED_OUT" -eq 1 ]; then bashunit::state::add_assertions_passed return 0 fi bashunit::assert::_inner_outcome_to_slot bashunit::assert::fail_with "" "${1-}" \ "to be a failing assertion, but got " "$_BASHUNIT_ASSERT_INNER_OUTCOME_OUT" return 1 } function assert_assertion_passes() { bashunit::assert::should_skip && return 0 bashunit::assert::_capture "$@" if [ "$_BASHUNIT_ASSERT_INNER_PASSED_OUT" -eq 1 ] && [ "$_BASHUNIT_ASSERT_INNER_FAILED_OUT" -eq 0 ]; then bashunit::state::add_assertions_passed return 0 fi bashunit::assert::_inner_outcome_to_slot bashunit::assert::fail_with "" "${1-}" \ "to be a passing assertion, but got " "$_BASHUNIT_ASSERT_INNER_OUTCOME_OUT" return 1 } function assert_assertion_fails_with() { bashunit::assert::should_skip && return 0 local expected_message=$1 shift bashunit::assert::_capture "$@" if [ "$_BASHUNIT_ASSERT_INNER_FAILED_OUT" -ne 1 ]; then bashunit::assert::_inner_outcome_to_slot bashunit::assert::fail_with "" "${1-}" \ "to be a failing assertion, but got " "$_BASHUNIT_ASSERT_INNER_OUTCOME_OUT" return 1 fi case "$_BASHUNIT_ASSERT_INNER_OUTPUT_OUT" in *"$expected_message"*) bashunit::state::add_assertions_passed return 0 ;; esac bashunit::assert::fail_with "" "$_BASHUNIT_ASSERT_INNER_OUTPUT_OUT" \ "to contain" "$expected_message" return 1 } # src/assert/once.sh _BASHUNIT_ASSERT_ONCE_ACTIVE=0 _BASHUNIT_ASSERT_ONCE_FRAME="" _BASHUNIT_ASSERT_ONCE_ABS=0 _BASHUNIT_ASSERT_ONCE_LABEL="" _BASHUNIT_ASSERT_ONCE_ACTUAL="" _BASHUNIT_ASSERT_ONCE_FAILED=0 _BASHUNIT_ASSERT_ONCE_IN_EXPECTED="" _BASHUNIT_ASSERT_ONCE_IN_CONDITION="" _BASHUNIT_ASSERT_ONCE_IN_ACTUAL="" _BASHUNIT_ASSERT_ONCE_TEST_LABEL="" function bashunit::assert::once_is_absorbing() { [ "$_BASHUNIT_ASSERT_ONCE_ACTIVE" -eq 1 ] || return 1 local index=$((${#FUNCNAME[@]} - _BASHUNIT_ASSERT_ONCE_ABS)) if [ "$index" -ge 0 ] && [ "${FUNCNAME[$index]-}" = "$_BASHUNIT_ASSERT_ONCE_FRAME" ]; then return 0 fi bashunit::assert::once_flush return 1 } function bashunit::assert::once_absorb_message() { _BASHUNIT_ASSERT_ONCE_FAILED=1 if [ -n "$_BASHUNIT_ASSERT_ONCE_IN_EXPECTED$_BASHUNIT_ASSERT_ONCE_IN_ACTUAL" ]; then return 0 fi _BASHUNIT_ASSERT_ONCE_IN_EXPECTED=${1-} _BASHUNIT_ASSERT_ONCE_IN_CONDITION=${2-} _BASHUNIT_ASSERT_ONCE_IN_ACTUAL=${3-} } function bashunit::assert::once_flush() { [ "$_BASHUNIT_ASSERT_ONCE_ACTIVE" -eq 1 ] || return 0 _BASHUNIT_ASSERT_ONCE_ACTIVE=0 if [ "$_BASHUNIT_ASSERT_ONCE_FAILED" -eq 0 ]; then return 0 fi _BASHUNIT_ASSERTIONS_PASSED=$((_BASHUNIT_ASSERTIONS_PASSED - 1)) local expected=$_BASHUNIT_ASSERT_ONCE_LABEL local condition="but got " local actual=$_BASHUNIT_ASSERT_ONCE_ACTUAL if [ -z "$expected" ]; then expected=$_BASHUNIT_ASSERT_ONCE_IN_EXPECTED condition=$_BASHUNIT_ASSERT_ONCE_IN_CONDITION actual=$_BASHUNIT_ASSERT_ONCE_IN_ACTUAL fi bashunit::assert::fail_with \ "$_BASHUNIT_ASSERT_ONCE_TEST_LABEL" "$expected" "$condition" "$actual" } function bashunit::assert::once_reset() { _BASHUNIT_ASSERT_ONCE_ACTIVE=0 _BASHUNIT_ASSERT_ONCE_FRAME="" _BASHUNIT_ASSERT_ONCE_ABS=0 _BASHUNIT_ASSERT_ONCE_LABEL="" _BASHUNIT_ASSERT_ONCE_ACTUAL="" _BASHUNIT_ASSERT_ONCE_FAILED=0 _BASHUNIT_ASSERT_ONCE_IN_EXPECTED="" _BASHUNIT_ASSERT_ONCE_IN_CONDITION="" _BASHUNIT_ASSERT_ONCE_IN_ACTUAL="" _BASHUNIT_ASSERT_ONCE_TEST_LABEL="" } function bashunit::assert_once() { bashunit::assert::should_skip && return 0 bashunit::assert::once_flush bashunit::assert::once_reset _BASHUNIT_ASSERT_ONCE_LABEL=${1-} _BASHUNIT_ASSERT_ONCE_ACTUAL=${2-} bashunit::state::add_assertions_passed bashunit::assert::label_to_slot "" _BASHUNIT_ASSERT_ONCE_TEST_LABEL=$_BASHUNIT_ASSERT_LABEL_OUT _BASHUNIT_ASSERT_ONCE_FRAME=${FUNCNAME[1]-} _BASHUNIT_ASSERT_ONCE_ABS=$((${#FUNCNAME[@]} - 1)) _BASHUNIT_ASSERT_ONCE_ACTIVE=1 } # src/assert/dates.sh function bashunit::date::to_epoch() { local input="$1" if [ -z "$input" ]; then echo "$input" return 1 fi case "$input" in *[!0-9]*) ;; *) echo "$input" return 0 ;; esac case "$input" in *Z) local utc_input="${input%Z}" local utc_norm="${utc_input/T/ }" local epoch 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 } 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 local normalized="$input" normalized="${normalized/T/ }" case "$normalized" in *[+-][0-9][0-9][0-9][0-9]) normalized="${normalized%[+-][0-9][0-9][0-9][0-9]}" ;; esac local epoch epoch=$(date -d "$input" +%s 2>/dev/null) && { echo "$epoch" return 0 } 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 if [ "$normalized" != "$input" ]; then epoch=$(date -d "$normalized" +%s 2>/dev/null) && { echo "$epoch" return 0 } fi epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$input" +%s 2>/dev/null) && { echo "$epoch" return 0 } epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$input" +%s 2>/dev/null) && { echo "$epoch" return 0 } epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$input" +%s 2>/dev/null) && { echo "$epoch" return 0 } epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$input 00:00:00" +%s 2>/dev/null) && { echo "$epoch" return 0 } echo "$input" return 1 } _BASHUNIT_DATE_EPOCH_OUT="" function bashunit::date::_epoch_or_fail() { local input=$1 local epoch if epoch="$(bashunit::date::to_epoch "$input")"; then _BASHUNIT_DATE_EPOCH_OUT=$epoch return 0 fi bashunit::assert::fail_with "" "${input}" "to be" "a valid date" return 1 } function assert_date_equals() { bashunit::assert::should_skip && return 0 local expected actual bashunit::date::_epoch_or_fail "$1" || return 0 expected=$_BASHUNIT_DATE_EPOCH_OUT bashunit::date::_epoch_or_fail "$2" || return 0 actual=$_BASHUNIT_DATE_EPOCH_OUT if [ "$actual" -ne "$expected" ]; then bashunit::assert::fail_with "" "${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 actual bashunit::date::_epoch_or_fail "$1" || return 0 expected=$_BASHUNIT_DATE_EPOCH_OUT bashunit::date::_epoch_or_fail "$2" || return 0 actual=$_BASHUNIT_DATE_EPOCH_OUT if [ "$actual" -ge "$expected" ]; then bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}" return fi bashunit::state::add_assertions_passed } function assert_date_after() { bashunit::assert::should_skip && return 0 local expected actual bashunit::date::_epoch_or_fail "$1" || return 0 expected=$_BASHUNIT_DATE_EPOCH_OUT bashunit::date::_epoch_or_fail "$2" || return 0 actual=$_BASHUNIT_DATE_EPOCH_OUT if [ "$actual" -le "$expected" ]; then bashunit::assert::fail_with "" "${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 to actual bashunit::date::_epoch_or_fail "$1" || return 0 from=$_BASHUNIT_DATE_EPOCH_OUT bashunit::date::_epoch_or_fail "$2" || return 0 to=$_BASHUNIT_DATE_EPOCH_OUT bashunit::date::_epoch_or_fail "$3" || return 0 actual=$_BASHUNIT_DATE_EPOCH_OUT if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then bashunit::assert::fail_with "" "${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 actual bashunit::date::_epoch_or_fail "$1" || return 0 expected=$_BASHUNIT_DATE_EPOCH_OUT bashunit::date::_epoch_or_fail "$2" || return 0 actual=$_BASHUNIT_DATE_EPOCH_OUT local delta="$3" local diff=$((actual - expected)) if [ "$diff" -lt 0 ]; then diff=$((-diff)) fi if [ "$diff" -gt "$delta" ]; then bashunit::assert::fail_with "" "${actual}" "to be within" "${delta} seconds of ${expected}" return fi bashunit::state::add_assertions_passed } # src/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 bashunit::assert::fail_with "" "${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 bashunit::assert::fail_with "" "${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 bashunit::assert::fail_with "" "${threshold_ms}" "to take at least (ms)" "${command}" return fi bashunit::state::add_assertions_passed } # src/assert/files.sh function assert_file_exists() { bashunit::assert::should_skip && return 0 local expected="$1" if [ ! -f "$expected" ]; then bashunit::assert::fail_with "${3:-}" "${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" if [ -f "$expected" ]; then bashunit::assert::fail_with "${3:-}" "${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" if [ ! -f "$expected" ]; then bashunit::assert::fail_with "${3:-}" "${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" if [ -s "$expected" ]; then bashunit::assert::fail_with "${3:-}" "${expected}" "to be empty" "but is not empty" return fi bashunit::state::add_assertions_passed } function bashunit::assert::file_state() { local path=$1 if [ ! -e "$path" ]; then _BASHUNIT_ASSERT_FILE_STATE_OUT="missing" elif [ ! -f "$path" ]; then _BASHUNIT_ASSERT_FILE_STATE_OUT="not-a-file" else _BASHUNIT_ASSERT_FILE_STATE_OUT="file" fi } _BASHUNIT_ASSERT_FILE_STATE_OUT="" function assert_is_file_readable() { bashunit::assert::should_skip && return 0 if [ $# -lt 1 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "path" "$#" return 2 fi local expected="$1" bashunit::assert::file_state "$expected" case "$_BASHUNIT_ASSERT_FILE_STATE_OUT" in missing) bashunit::assert::fail_with "${2:-}" "${expected}" "to be readable" "but does not exist" return ;; not-a-file) bashunit::assert::fail_with "${2:-}" "${expected}" "to be readable" "but is not a file" return ;; esac if [ ! -r "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${expected}" "to be readable" "but is not readable" return fi bashunit::state::add_assertions_passed } function assert_is_file_not_readable() { bashunit::assert::should_skip && return 0 if [ $# -lt 1 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "path" "$#" return 2 fi local expected="$1" bashunit::assert::file_state "$expected" case "$_BASHUNIT_ASSERT_FILE_STATE_OUT" in missing) bashunit::assert::fail_with "${2:-}" "${expected}" "to not be readable" "but does not exist" return ;; not-a-file) bashunit::assert::fail_with "${2:-}" "${expected}" "to not be readable" "but is not a file" return ;; esac if [ -r "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${expected}" "to not be readable" "but is readable" return fi bashunit::state::add_assertions_passed } function assert_is_file_writable() { bashunit::assert::should_skip && return 0 if [ $# -lt 1 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "path" "$#" return 2 fi local expected="$1" bashunit::assert::file_state "$expected" case "$_BASHUNIT_ASSERT_FILE_STATE_OUT" in missing) bashunit::assert::fail_with "${2:-}" "${expected}" "to be writable" "but does not exist" return ;; not-a-file) bashunit::assert::fail_with "${2:-}" "${expected}" "to be writable" "but is not a file" return ;; esac if [ ! -w "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${expected}" "to be writable" "but is not writable" return fi bashunit::state::add_assertions_passed } function assert_is_file_not_writable() { bashunit::assert::should_skip && return 0 if [ $# -lt 1 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "path" "$#" return 2 fi local expected="$1" bashunit::assert::file_state "$expected" case "$_BASHUNIT_ASSERT_FILE_STATE_OUT" in missing) bashunit::assert::fail_with "${2:-}" "${expected}" "to not be writable" "but does not exist" return ;; not-a-file) bashunit::assert::fail_with "${2:-}" "${expected}" "to not be writable" "but is not a file" return ;; esac if [ -w "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${expected}" "to not be writable" "but is writable" return fi bashunit::state::add_assertions_passed } function assert_is_file_executable() { bashunit::assert::should_skip && return 0 if [ $# -lt 1 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "path" "$#" return 2 fi local expected="$1" bashunit::assert::file_state "$expected" case "$_BASHUNIT_ASSERT_FILE_STATE_OUT" in missing) bashunit::assert::fail_with "${2:-}" "${expected}" "to be executable" "but does not exist" return ;; not-a-file) bashunit::assert::fail_with "${2:-}" "${expected}" "to be executable" "but is not a file" return ;; esac if [ ! -x "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${expected}" "to be executable" "but is not executable" return fi bashunit::state::add_assertions_passed } function assert_is_file_not_executable() { bashunit::assert::should_skip && return 0 if [ $# -lt 1 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "path" "$#" return 2 fi local expected="$1" bashunit::assert::file_state "$expected" case "$_BASHUNIT_ASSERT_FILE_STATE_OUT" in missing) bashunit::assert::fail_with "${2:-}" "${expected}" "to not be executable" "but does not exist" return ;; not-a-file) bashunit::assert::fail_with "${2:-}" "${expected}" "to not be executable" "but is not a file" return ;; esac if [ -x "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${expected}" "to not be executable" "but is executable" return fi bashunit::state::add_assertions_passed } function assert_is_file_not_empty() { bashunit::assert::should_skip && return 0 if [ $# -lt 1 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "path" "$#" return 2 fi local expected="$1" bashunit::assert::file_state "$expected" case "$_BASHUNIT_ASSERT_FILE_STATE_OUT" in missing) bashunit::assert::fail_with "${2:-}" "${expected}" "to not be empty" "but does not exist" return ;; not-a-file) bashunit::assert::fail_with "${2:-}" "${expected}" "to not be empty" "but is not a file" return ;; esac if [ ! -s "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${expected}" "to not be empty" "but is 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 bashunit::assert::fail_with "" "${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 bashunit::assert::fail_with "" "${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 -e "$string" -- "$file"; then bashunit::assert::fail_with "" "${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 -F -q -e "$string" -- "$file"; then bashunit::assert::fail_with "" "${file}" "to not contain" "${string}" return fi bashunit::state::add_assertions_passed } function bashunit::assert::_octal_to_decimal() { local mode="$1" case "$mode" in '' | *[!0-7]*) return 1 ;; esac printf '%d' "$((8#$mode))" } function assert_file_permissions() { bashunit::assert::should_skip && return 0 local expected="$1" local file="$2" if [ ! -e "$file" ]; then bashunit::assert::fail_with "" "${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::fail_with "" "${file}" \ "to have permissions ${expected}" "but got ${actual}" return fi bashunit::state::add_assertions_passed } function assert_is_symlink() { bashunit::assert::should_skip && return 0 local expected="$1" if [ ! -L "$expected" ]; then bashunit::assert::fail_with "${3:-}" "${expected}" "to be a symlink" "but is not a symlink" return fi bashunit::state::add_assertions_passed } function assert_is_not_symlink() { bashunit::assert::should_skip && return 0 local expected="$1" if [ -L "$expected" ]; then bashunit::assert::fail_with "${3:-}" "${expected}" "not to be a symlink" "but is a symlink" return fi bashunit::state::add_assertions_passed } function assert_symlink_to() { bashunit::assert::should_skip && return 0 if [ $# -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected_target, path" "$#" return 2 fi local expected="$1" local path="$2" if [ ! -L "$path" ]; then bashunit::assert::fail_with "${3:-}" "${path}" "to be a symlink" "but is not a symlink" return fi local actual actual=$(readlink "$path") if [ "$actual" != "$expected" ]; then bashunit::assert::fail_with "${3:-}" "${expected}" \ "to be the target of ${path}, but got " "${actual}" return fi bashunit::state::add_assertions_passed } # src/assert/folders.sh function assert_directory_exists() { bashunit::assert::should_skip && return 0 local expected="$1" if [ ! -d "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${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" if [ -d "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${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" if [ ! -d "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${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" if [ ! -d "$expected" ] || [ -n "$(ls -A "$expected")" ]; then bashunit::assert::fail_with "${2:-}" "${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" if [ ! -d "$expected" ] || [ -z "$(ls -A "$expected")" ]; then bashunit::assert::fail_with "${2:-}" "${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" if [ ! -d "$expected" ] || [ ! -r "$expected" ] || [ ! -x "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${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" if [ ! -d "$expected" ] || { [ -r "$expected" ] && [ -x "$expected" ]; }; then bashunit::assert::fail_with "${2:-}" "${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" if [ ! -d "$expected" ] || [ ! -w "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${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" if [ ! -d "$expected" ] || [ -w "$expected" ]; then bashunit::assert::fail_with "${2:-}" "${expected}" "to be not writable" "but is writable" return fi bashunit::state::add_assertions_passed } # src/assert/json.sh function bashunit::assert_json::require_jq() { if ! command -v jq >/dev/null 2>&1; then bashunit::skip::__mark "jq is required for JSON assertions" 3 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 bashunit::assert::fail_with "" "${json}" "to have key" "${key}" return fi bashunit::state::add_assertions_passed } function assert_json_key_not_exists() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "key, json" "$#" return 2 fi bashunit::assert_json::require_jq || return 0 local key="$1" local json="$2" if ! printf '%s' "$json" | jq -e 'true' >/dev/null 2>&1; then bashunit::assert::fail_with "" "${json}" "to be valid JSON" "" return fi local exists if ! exists=$(printf '%s' "$json" | jq -r \ "(path($key)) as \$path | if \$path == [] then true else any(paths; . == \$path) end" \ 2>/dev/null); then bashunit::assert::fail_with "" "${json}" "to use a valid key path" "${key}" return fi if [ "$exists" = true ]; then bashunit::assert::fail_with "" "${json}" "to not 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 bashunit::assert::fail_with "" "${json}" "to have key" "${key}" return fi if [ "$result" != "$expected" ]; then bashunit::assert::fail_with "" "${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 actual_valid=true expected_valid=true expected_sorted=$(printf '%s' "$expected" | jq -S '.' 2>/dev/null) || expected_valid=false local actual_sorted actual_sorted=$(printf '%s' "$actual" | jq -S '.' 2>/dev/null) || actual_valid=false if [ "$expected_valid" = false ] || [ "$actual_valid" = false ] || [ "$expected_sorted" != "$actual_sorted" ]; then bashunit::assert::fail_with "" "${expected}" "but got " "${actual}" return fi bashunit::state::add_assertions_passed } function assert_json_length() { bashunit::assert::should_skip && return 0 if [ "$#" -lt 3 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "expected, key, json" "$#" return 2 fi local expected="$1" local key="$2" local json="$3" case "$expected" in '' | *[!0-9]*) bashunit::assert::usage_error_detail "${FUNCNAME[0]}" \ "expects a non-negative integer length, got '$expected'" return 2 ;; esac bashunit::assert_json::require_jq || return 0 if ! printf '%s' "$json" | jq -e 'true' >/dev/null 2>&1; then bashunit::assert::fail_with "" "${json}" "to be valid JSON" "" return fi local exists if ! exists=$(printf '%s' "$json" | jq -r \ "(path($key)) as \$path | if \$path == [] then true else any(paths; . == \$path) end" \ 2>/dev/null); then bashunit::assert::fail_with "" "${json}" "to use a valid key path" "${key}" return fi if [ "$exists" != true ]; then bashunit::assert::fail_with "" "${json}" "to have path" "${key}" return fi local actual local length_filter length_filter="$key | if type == \"array\" or type == \"object\" or type == \"string\"" length_filter="$length_filter then length else error(\"unsupported type\") end" if ! actual=$(printf '%s' "$json" | jq -r "$length_filter" 2>/dev/null); then bashunit::assert::fail_with "" "${json}" "to have a measurable length at path" "${key}" return fi if [ "$actual" != "$expected" ]; then bashunit::assert::fail_with "" "${expected}" "but got " "${actual}" return fi bashunit::state::add_assertions_passed } # src/assert/snapshot.sh 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 bashunit::snapshot::assert "$actual" "$snapshot_file" "$test_fn" } function assert_match_named_snapshot() { local snapshot_name=$1 local _snapshot_normalized bashunit::snapshot::normalize_actual "$2" local actual=$_snapshot_normalized bashunit::helper::find_test_function_name_to_slot local test_fn=$_BASHUNIT_HELPER_TESTFN_OUT bashunit::snapshot::resolve_file "" "$test_fn" "$snapshot_name" local snapshot_file=$_BASHUNIT_SNAPSHOT_FILE_OUT bashunit::snapshot::assert "$actual" "$snapshot_file" "$test_fn" } function assert_match_snapshot_ignore_colors() { 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 bashunit::snapshot::assert "$actual" "$snapshot_file" "$test_fn" } function assert_match_named_snapshot_ignore_colors() { local snapshot_name=$1 local stripped=$2 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 "" "$test_fn" "$snapshot_name" local snapshot_file=$_BASHUNIT_SNAPSHOT_FILE_OUT bashunit::snapshot::assert "$actual" "$snapshot_file" "$test_fn" } function bashunit::snapshot::normalize_path() { local path="$1" while [ "${path#./}" != "$path" ]; do path="${path#./}" done while [ "${path%%//*}" != "$path" ]; do path="${path%%//*}/${path#*//}" done builtin echo "$path" } _BASHUNIT_SNAPSHOT_UNUSED_OUT="" _BASHUNIT_SNAPSHOT_UNUSED_COUNT_OUT=0 function bashunit::snapshot::collect_unused() { local -a search_paths=() local path for path in "$@"; do if [ -d "$path" ]; then search_paths[${#search_paths[@]}]="$path" elif [ -f "$path" ]; then case "$path" in */*) search_paths[${#search_paths[@]}]="${path%/*}" ;; *) search_paths[${#search_paths[@]}]="." ;; esac fi done if [ "${#search_paths[@]}" -eq 0 ]; then search_paths[0]="." fi local owners="" for path in "$@"; do [ -f "$path" ] || continue bashunit::helper::normalize_variable_name_to_slot "${path##*/}" owners="$owners$_BASHUNIT_HELPER_VARNAME_OUT " done local used="" if [ -f "${SNAPSHOT_USED_OUTPUT_PATH:-}" ]; then local used_path while IFS= read -r used_path; do [ -z "$used_path" ] && continue used="$used$(bashunit::snapshot::normalize_path "$used_path") " done <"$SNAPSHOT_USED_OUTPUT_PATH" fi local unused="" local total=0 local dir file normalized while IFS= read -r dir; do [ -z "$dir" ] && continue for file in "$dir"/*.snapshot; do [ -f "$file" ] || continue normalized="$(bashunit::snapshot::normalize_path "$file")" case "$used" in *"$normalized"$'\n'*) continue ;; esac local owner="${file##*/}" owner="${owner%%.*}" case "$owners" in *"$owner"$'\n'*) ;; *) continue ;; esac total=$((total + 1)) unused="$unused $normalized " done done < <(find "${search_paths[@]}" -type d -name snapshots 2>/dev/null | sort -u) _BASHUNIT_SNAPSHOT_UNUSED_OUT="$unused" _BASHUNIT_SNAPSHOT_UNUSED_COUNT_OUT="$total" } function bashunit::snapshot::report_unused() { bashunit::snapshot::collect_unused "$@" if [ "$_BASHUNIT_SNAPSHOT_UNUSED_COUNT_OUT" -eq 0 ]; then printf "\n%sNo unused snapshots.%s\n" \ "${_BASHUNIT_COLOR_FAINT}" "${_BASHUNIT_COLOR_DEFAULT}" return fi printf "\n%sUnused snapshots (%s), no test resolved them:%s\n%s" \ "${_BASHUNIT_COLOR_SKIPPED}" "$_BASHUNIT_SNAPSHOT_UNUSED_COUNT_OUT" \ "${_BASHUNIT_COLOR_DEFAULT}" "$_BASHUNIT_SNAPSHOT_UNUSED_OUT" printf "%sNothing was deleted. Run with --snapshot-prune to remove them.%s\n" \ "${_BASHUNIT_COLOR_FAINT}" "${_BASHUNIT_COLOR_DEFAULT}" } function bashunit::snapshot::prune_unused() { if [ "${_BASHUNIT_TESTS_FAILED:-0}" -gt 0 ]; then printf "\n%sSnapshots were not pruned: the run has failing tests, so a%s\n" \ "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" printf "%ssnapshot may be unresolved rather than unused.%s\n" \ "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" return fi bashunit::snapshot::collect_unused "$@" if [ "$_BASHUNIT_SNAPSHOT_UNUSED_COUNT_OUT" -eq 0 ]; then printf "\n%sNo unused snapshots.%s\n" \ "${_BASHUNIT_COLOR_FAINT}" "${_BASHUNIT_COLOR_DEFAULT}" return fi printf "\n%sDeleted %s unused snapshot(s):%s\n" \ "${_BASHUNIT_COLOR_SKIPPED}" "$_BASHUNIT_SNAPSHOT_UNUSED_COUNT_OUT" \ "${_BASHUNIT_COLOR_DEFAULT}" local entry path while IFS= read -r entry; do path="${entry#"${entry%%[![:space:]]*}"}" [ -n "$path" ] || continue case "$path" in *.snapshot) ;; *) continue ;; esac [ -f "$path" ] || continue printf " %s\n" "$path" rm -f "$path" done <>"${SNAPSHOT_USED_OUTPUT_PATH:-/dev/null}" 2>/dev/null || true fi if [ ! -f "$snapshot_file" ]; then if ! bashunit::env::is_snapshot_create_enabled; then bashunit::snapshot::fail_missing "$snapshot_file" "$test_fn" return fi bashunit::snapshot::initialize "$snapshot_file" "$actual" return fi if bashunit::snapshot::update "$snapshot_file" "$actual"; then return fi bashunit::snapshot::compare "$actual" "$snapshot_file" "$test_fn" } function bashunit::snapshot::fail_missing() { local path="$1" local func_name="$2" local label label=$(bashunit::helper::normalize_test_function_name "$func_name") bashunit::state::add_assertions_failed bashunit::console_results::print_failed_test "$label" "$path" \ "does not exist; record it with a run without" "--no-snapshot-create" } 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 printf '%s' "$actual" | REGEX="$regex" perl -0 -e ' my $r = $ENV{REGEX}; my $input = join("", ); exit($input =~ /$r/s ? 0 : 1); ' && return 0 || return 1 fi if bashunit::dependencies::has_awk; then printf '%s' "$actual" | REGEX="$regex" awk ' BEGIN { RS = "\001"; re = ENVIRON["REGEX"] } { exit !($0 ~ re) } ' && return 0 || return 1 fi printf '%sCannot match a snapshot placeholder: neither perl nor awk is available.%s\n' \ "${_BASHUNIT_COLOR_FAILED:-}" "${_BASHUNIT_COLOR_DEFAULT:-}" >&2 return 1 } _BASHUNIT_SNAPSHOT_FILE_OUT="" function bashunit::snapshot::resolve_file() { local file_hint="$1" local func_name="$2" local snapshot_name="${3:-}" if [ -n "$file_hint" ]; then _BASHUNIT_SNAPSHOT_FILE_OUT=$file_hint return fi local src="${4:-${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 if [ -n "$snapshot_name" ]; then bashunit::helper::normalize_variable_name_to_slot "$snapshot_name" name="$name.$_BASHUNIT_HELPER_VARNAME_OUT" fi case "$dir_part" in /*) _BASHUNIT_SNAPSHOT_FILE_OUT="${dir_part}/snapshots/${test_file}.${name}.snapshot" ;; *) _BASHUNIT_SNAPSHOT_FILE_OUT="./${dir_part}/snapshots/${test_file}.${name}.snapshot" ;; esac } 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::update() { local path="$1" local actual="$2" bashunit::env::is_snapshot_update_enabled || return 1 local placeholder="${BASHUNIT_SNAPSHOT_PLACEHOLDER:-::ignore::}" local snapshot snapshot=$(<"$path") case "$snapshot" in *"$placeholder"*) printf "%sNot updating %s: it contains the placeholder '%s'.%s\n" \ "${_BASHUNIT_COLOR_SKIPPED:-}" "$path" "$placeholder" "${_BASHUNIT_COLOR_DEFAULT:-}" >&2 return 1 ;; esac echo "$actual" >"$path" bashunit::state::add_assertions_snapshot return 0 } function bashunit::snapshot::compare() { local actual="$1" local snapshot_path="$2" local func_name="$3" local snapshot snapshot=$(<"$snapshot_path") snapshot="${snapshot//$'\r'/}" if [ "$actual" = "$snapshot" ]; then bashunit::state::add_assertions_passed return fi local placeholder="${BASHUNIT_SNAPSHOT_PLACEHOLDER:-::ignore::}" case "$snapshot" in *"$placeholder"*) if bashunit::snapshot::match_with_placeholder "$actual" "$snapshot"; then bashunit::state::add_assertions_passed return fi ;; esac local label=$(bashunit::helper::normalize_test_function_name "$func_name") bashunit::state::add_assertions_failed bashunit::console_results::print_failed_snapshot_test "$label" "$snapshot_path" "$actual" return 1 } # src/doubles/index.sh # src/doubles/mock.sh declare -a _BASHUNIT_MOCKED_FUNCTIONS=() function bashunit::unmock() { local command=$1 if [ "${#_BASHUNIT_MOCKED_FUNCTIONS[@]}" -eq 0 ]; then return fi local i for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do if [ "${_BASHUNIT_MOCKED_FUNCTIONS[$i]:-}" = "$command" ]; then unset "_BASHUNIT_MOCKED_FUNCTIONS[$i]" unset -f "$command" bashunit::sandbox::restore_shim "$command" local variable variable="$(bashunit::helper::normalize_variable_name "$command")" local times_file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" local params_file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" local sequence_file_var="_BASHUNIT_MOCK_${variable}_SEQUENCE_FILE" [ -f "${!sequence_file_var-}" ] && rm -f "${!sequence_file_var}" unset "$sequence_file_var" [ -f "${!times_file_var-}" ] && rm -f "${!times_file_var}" [ -f "${!params_file_var-}" ] && rm -f "${!params_file_var}" unset "$times_file_var" unset "$params_file_var" break fi done } function bashunit::doubles::is_exit_code() { case "$1" in '' | *[!0-9]*) return 1 ;; esac return 0 } function bashunit::doubles::refuse_unusable_name() { local fn=$1 command=$2 case "$command" in '') bashunit::assert::fail_with "" "$fn" "expects a command name, got" "nothing" return 0 ;; *[[:space:]\;\|\&\(\)\{\}\<\>\"\'\`]*) bashunit::assert::fail_with "" "$command" \ "is not a usable command name for $fn; name the command alone, as in" "$fn ls" return 0 ;; esac return 1 } function bashunit::mock() { local command=$1 shift if bashunit::doubles::refuse_unusable_name "bashunit::mock" "$command"; then return 1 fi if [ $# -eq 1 ] && bashunit::doubles::is_exit_code "$1"; then eval "function $command() { return $1; }" elif [ $# -gt 0 ]; then eval "function $command() { $* \"\$@\"; }" else eval "function $command() { builtin echo \"$($CAT)\" ; }" fi export -f "${command?}" _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" } function bashunit::mock_sequence() { local command=$1 shift if bashunit::doubles::refuse_unusable_name "bashunit::mock_sequence" "$command"; then return 1 fi if [ $# -eq 0 ]; then bashunit::assert::usage_error_detail "bashunit::mock_sequence" \ "expects at least one answer after the command" return 2 fi local variable bashunit::helper::normalize_variable_name_to_slot "$command" variable=$_BASHUNIT_HELPER_VARNAME_OUT local test_id="${BASHUNIT_CURRENT_TEST_ID:-global}" local step_file step_file=$(bashunit::temp_file "${test_id}_${variable}_sequence") builtin echo 1 >"$step_file" export "_BASHUNIT_MOCK_${variable}_SEQUENCE_FILE"="$step_file" local times_file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" local params_file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" local record="" if [ -n "${!times_file_var-}" ]; then record="bashunit::doubles::record_call '${!params_file_var}' '${!times_file_var}' \"\$@\"" fi local total=$# local index=0 local arms="" local entry body for entry in "$@"; do index=$((index + 1)) if bashunit::doubles::is_exit_code "$entry"; then body="return $entry" else body="$entry \"\$@\"" fi if [ "$index" -eq "$total" ]; then arms="$arms *) $body ;;" else arms="$arms $index) $body ;;" fi done eval "function $command() { $record local _step=1 read -r _step < '$step_file' 2>/dev/null || _step=1 case \"\$_step\" in '' | *[!0-9]*) _step=1 ;; esac if [ \"\$_step\" -lt $total ]; then builtin echo \"\$((_step + 1))\" > '$step_file' fi case \"\$_step\" in$arms esac }" export -f "${command?}" export -f bashunit::doubles::record_call _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" } # src/doubles/spy.sh _BASHUNIT_SPY_TIMES_OUT=0 _BASHUNIT_SPY_REGISTERED_OUT=false function bashunit::spy::times_to_slot() { local command="$1" local variable bashunit::helper::normalize_variable_name_to_slot "$command" variable=$_BASHUNIT_HELPER_VARNAME_OUT local file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE" _BASHUNIT_SPY_TIMES_OUT=0 _BASHUNIT_SPY_REGISTERED_OUT=false if [ -n "${!file_var-}" ]; then _BASHUNIT_SPY_REGISTERED_OUT=true fi if [ -f "${!file_var-}" ]; then local times_line="" read -r times_line <"${!file_var}" 2>/dev/null || times_line="" case "$times_line" in '' | *[!0-9]*) _BASHUNIT_SPY_TIMES_OUT=0 ;; *) _BASHUNIT_SPY_TIMES_OUT=$times_line ;; esac fi } _BASHUNIT_SPY_CALL_OUT="" _BASHUNIT_SPY_CALL_TOTAL_OUT=0 function bashunit::spy::read_call_to_slots() { local file=$1 local index=${2:-} _BASHUNIT_SPY_CALL_OUT="" _BASHUNIT_SPY_CALL_TOTAL_OUT=0 if [ -z "$file" ] || [ ! -f "$file" ]; then return fi local current while IFS= read -r current; do _BASHUNIT_SPY_CALL_TOTAL_OUT=$((_BASHUNIT_SPY_CALL_TOTAL_OUT + 1)) if [ -z "$index" ] || [ "$_BASHUNIT_SPY_CALL_TOTAL_OUT" = "$index" ]; then _BASHUNIT_SPY_CALL_OUT=$current fi done <"$file" } function bashunit::spy::compared_call() { if [ -n "$1" ]; then builtin echo "call $1 of $2" elif [ "$2" -eq 1 ]; then builtin echo "the only call" else builtin echo "the last of $2 calls" fi } _BASHUNIT_SPY_CALL_LOG_MAX=10 _BASHUNIT_SPY_CALL_LOG_OUT="" function bashunit::spy::call_log_to_slot() { local command=$1 local field=${2:-raw} _BASHUNIT_SPY_CALL_LOG_OUT="" local variable bashunit::helper::normalize_variable_name_to_slot "$command" variable=$_BASHUNIT_HELPER_VARNAME_OUT local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" if [ -z "${!file_var-}" ] || [ ! -f "${!file_var}" ]; then return fi local entries="" local total=0 local shown=0 local line value while IFS= read -r line; do total=$((total + 1)) if [ "$shown" -lt "$_BASHUNIT_SPY_CALL_LOG_MAX" ]; then if [ "$field" = args ]; then value=${line#*$'\x1e'} value=${value//$'\x1f'/ } else value=${line%%$'\x1e'*} fi entries="$entries ${_BASHUNIT_COLOR_FAINT}${total}:${_BASHUNIT_COLOR_DEFAULT} ${value}" shown=$((shown + 1)) fi done <"${!file_var}" if [ "$total" -eq 0 ]; then return fi _BASHUNIT_SPY_CALL_LOG_OUT="\ ${_BASHUNIT_COLOR_FAINT}Recorded calls to '${command}' (${total}):\ ${_BASHUNIT_COLOR_DEFAULT}${entries}" local remaining=$((total - shown)) if [ "$remaining" -gt 0 ]; then _BASHUNIT_SPY_CALL_LOG_OUT="$_BASHUNIT_SPY_CALL_LOG_OUT ${_BASHUNIT_COLOR_FAINT}… and ${remaining} more${_BASHUNIT_COLOR_DEFAULT}" fi } function bashunit::spy::fail_unregistered() { bashunit::state::add_assertions_failed bashunit::console_results::print_failed_test "$2" "$1" \ "was never registered as a spy; call it first with" "bashunit::spy $1" } _BASHUNIT_SPY_SERIALIZED_OUT="" function bashunit::spy::serialize_args_to_slot() { local serialized="" local arg for arg in "$@"; do serialized="$serialized$(builtin printf '%q' "$arg")"$'\x1f' done _BASHUNIT_SPY_SERIALIZED_OUT=${serialized%$'\x1f'} } function bashunit::doubles::record_call() { local params_file=$1 local times_file=$2 shift 2 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 count="" read -r count <"$times_file" 2>/dev/null || count="" case "$count" in '' | *[!0-9]*) count=0 ;; esac builtin echo "$((count + 1))" >"$times_file" } function bashunit::spy() { local command=$1 local exit_code_or_impl="${2:-}" if bashunit::doubles::refuse_unusable_name "bashunit::spy" "$command"; then return 1 fi local variable bashunit::helper::normalize_variable_name_to_slot "$command" variable=$_BASHUNIT_HELPER_VARNAME_OUT local times_file params_file local test_id="${BASHUNIT_CURRENT_TEST_ID:-global}" times_file=$(bashunit::temp_file "${test_id}_${variable}_times") params_file=$(bashunit::temp_file "${test_id}_${variable}_params") echo 0 >"$times_file" : >"$params_file" export "_BASHUNIT_SPY_${variable}_TIMES_FILE"="$times_file" export "_BASHUNIT_SPY_${variable}_PARAMS_FILE"="$params_file" local body_suffix="" if bashunit::doubles::is_exit_code "$exit_code_or_impl"; 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() { bashunit::doubles::record_call '$params_file' '$times_file' \"\$@\" $body_suffix }" export -f "${command?}" export -f bashunit::doubles::record_call _BASHUNIT_MOCKED_FUNCTIONS[${#_BASHUNIT_MOCKED_FUNCTIONS[@]}]="$command" } # src/doubles/assertions.sh function assert_have_been_called() { local command=$1 bashunit::spy::times_to_slot "$command" local times=$_BASHUNIT_SPY_TIMES_OUT local label="${2:-}" if [ -z "$label" ]; then bashunit::helper::normalize_test_function_name_to_slot "${FUNCNAME[1]}" label=$_BASHUNIT_HELPER_NORMALIZED_OUT fi if [ "$_BASHUNIT_SPY_REGISTERED_OUT" = false ]; then bashunit::spy::fail_unregistered "$command" "$label" return fi if [ "$times" -eq 0 ]; then bashunit::state::add_assertions_failed bashunit::spy::call_log_to_slot "$command" bashunit::console_results::print_failed_test "${label}" "${command}" "to have been called" "once" \ "" "" "$_BASHUNIT_SPY_CALL_LOG_OUT" return fi bashunit::state::add_assertions_passed } function assert_have_been_called_with() { local command=$1 shift local index="" case "${!#}" in '' | *[!0-9]*) ;; *) index=${!#} set -- "${@:1:$#-1}" ;; esac local expected="$*" local variable bashunit::helper::normalize_variable_name_to_slot "$command" variable=$_BASHUNIT_HELPER_VARNAME_OUT local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" local label bashunit::helper::normalize_test_function_name_to_slot "${FUNCNAME[1]}" label=$_BASHUNIT_HELPER_NORMALIZED_OUT if [ -z "${!file_var-}" ]; then bashunit::spy::fail_unregistered "$command" "$label" return fi bashunit::spy::read_call_to_slots "${!file_var-}" "$index" local raw=${_BASHUNIT_SPY_CALL_OUT%%$'\x1e'*} local total=$_BASHUNIT_SPY_CALL_TOTAL_OUT if [ "$expected" != "$raw" ]; then bashunit::state::add_assertions_failed bashunit::spy::call_log_to_slot "$command" bashunit::console_results::print_failed_test "$label" "$expected" "but got " "$raw" \ "compared" "$(bashunit::spy::compared_call "$index" "$total")" \ "$_BASHUNIT_SPY_CALL_LOG_OUT" return fi bashunit::state::add_assertions_passed } function assert_have_been_called_with_args() { local command=$1 shift bashunit::spy::serialize_args_to_slot "$@" local expected=$_BASHUNIT_SPY_SERIALIZED_OUT local variable bashunit::helper::normalize_variable_name_to_slot "$command" variable=$_BASHUNIT_HELPER_VARNAME_OUT local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" local label bashunit::helper::normalize_test_function_name_to_slot "${FUNCNAME[1]}" label=$_BASHUNIT_HELPER_NORMALIZED_OUT if [ -z "${!file_var-}" ]; then bashunit::spy::fail_unregistered "$command" "$label" return fi bashunit::spy::read_call_to_slots "${!file_var-}" local actual="" case "$_BASHUNIT_SPY_CALL_OUT" in *$'\x1e'*) actual=${_BASHUNIT_SPY_CALL_OUT#*$'\x1e'} ;; esac if [ "$expected" != "$actual" ]; then bashunit::state::add_assertions_failed bashunit::spy::call_log_to_slot "$command" args bashunit::console_results::print_failed_test "$label" \ "${expected//$'\x1f'/ }" "but got " "${actual//$'\x1f'/ }" \ "compared" "$(bashunit::spy::compared_call "" "$_BASHUNIT_SPY_CALL_TOTAL_OUT")" \ "$_BASHUNIT_SPY_CALL_LOG_OUT" return fi bashunit::state::add_assertions_passed } function assert_have_been_called_with_any() { local command=$1 shift local expected="$*" local variable bashunit::helper::normalize_variable_name_to_slot "$command" variable=$_BASHUNIT_HELPER_VARNAME_OUT local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" local label bashunit::helper::normalize_test_function_name_to_slot "${FUNCNAME[1]}" label=$_BASHUNIT_HELPER_NORMALIZED_OUT if [ -z "${!file_var-}" ]; then bashunit::spy::fail_unregistered "$command" "$label" return fi local total=0 local found=false local line if [ -f "${!file_var}" ]; then while IFS= read -r line; do total=$((total + 1)) if [ "${line%%$'\x1e'*}" = "$expected" ]; then found=true break fi done <"${!file_var}" fi if [ "$found" = false ]; then bashunit::state::add_assertions_failed bashunit::spy::call_log_to_slot "$command" bashunit::console_results::print_failed_test "$label" "$expected" \ "not found in any of" "${total} calls" "" "" "$_BASHUNIT_SPY_CALL_LOG_OUT" return fi bashunit::state::add_assertions_passed } function assert_have_been_called_times() { local expected_count=$1 local command=$2 case "$expected_count" in '' | *[!0-9]*) bashunit::assert::usage_error_detail "${FUNCNAME[0]}" \ "expects a numeric count first (expected_count, command), got '$expected_count'" return 2 ;; esac bashunit::spy::times_to_slot "$command" local times=$_BASHUNIT_SPY_TIMES_OUT local label="${3:-}" if [ -z "$label" ]; then bashunit::helper::normalize_test_function_name_to_slot "${FUNCNAME[1]}" label=$_BASHUNIT_HELPER_NORMALIZED_OUT fi if [ "$_BASHUNIT_SPY_REGISTERED_OUT" = false ]; then bashunit::spy::fail_unregistered "$command" "$label" return fi if [ "$times" -ne "$expected_count" ]; then bashunit::state::add_assertions_failed bashunit::spy::call_log_to_slot "$command" bashunit::console_results::print_failed_test "${label}" "${command}" \ "to have been called" "${expected_count} times" \ "actual" "${times} times" "$_BASHUNIT_SPY_CALL_LOG_OUT" return fi bashunit::state::add_assertions_passed } function assert_have_been_called_nth_with() { local nth=$1 case "$nth" in '' | *[!0-9]*) bashunit::assert::usage_error_detail "${FUNCNAME[0]}" \ "expects a numeric call index first (nth, command, expected_args), got '$nth'" return 2 ;; esac local command=$2 shift 2 local expected="$*" local variable bashunit::helper::normalize_variable_name_to_slot "$command" variable=$_BASHUNIT_HELPER_VARNAME_OUT local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" local label bashunit::helper::normalize_test_function_name_to_slot "${FUNCNAME[1]}" label=$_BASHUNIT_HELPER_NORMALIZED_OUT bashunit::spy::times_to_slot "$command" local times=$_BASHUNIT_SPY_TIMES_OUT if [ "$_BASHUNIT_SPY_REGISTERED_OUT" = false ]; then bashunit::spy::fail_unregistered "$command" "$label" return fi if [ "$nth" -gt "$times" ]; then bashunit::state::add_assertions_failed bashunit::spy::call_log_to_slot "$command" bashunit::console_results::print_failed_test "${label}" \ "expected call" "at index ${nth} but" "only called ${times} times" \ "" "" "$_BASHUNIT_SPY_CALL_LOG_OUT" 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::spy::call_log_to_slot "$command" bashunit::console_results::print_failed_test "${label}" \ "$expected" "but got " "$raw" "" "" "$_BASHUNIT_SPY_CALL_LOG_OUT" return fi bashunit::state::add_assertions_passed } function assert_have_never_been_called() { local command=$1 bashunit::spy::times_to_slot "$command" local times=$_BASHUNIT_SPY_TIMES_OUT local label="${2:-}" if [ -z "$label" ]; then bashunit::helper::normalize_test_function_name_to_slot "${FUNCNAME[1]}" label=$_BASHUNIT_HELPER_NORMALIZED_OUT fi if [ "$_BASHUNIT_SPY_REGISTERED_OUT" = false ]; then bashunit::spy::fail_unregistered "$command" "$label" return fi if [ "$times" -ne 0 ]; then bashunit::state::add_assertions_failed bashunit::spy::call_log_to_slot "$command" local unit="times" if [ "$times" -eq 1 ]; then unit="time" fi bashunit::console_results::print_failed_test "${label}" "${command}" \ "to have never been called" "" \ "actual" "${times} ${unit}" "$_BASHUNIT_SPY_CALL_LOG_OUT" return fi bashunit::state::add_assertions_passed } function assert_not_called() { local command=$1 local label="${2:-}" if [ -z "$label" ]; then bashunit::helper::normalize_test_function_name_to_slot "${FUNCNAME[1]}" label=$_BASHUNIT_HELPER_NORMALIZED_OUT fi assert_have_been_called_times 0 "$command" "$label" } # src/reports/index.sh # src/reports/collect.sh function bashunit::reports::__strip_ansi() { printf '%s' "$1" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' } _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=() _BASHUNIT_REPORTS_TEST_RETRIES=() _BASHUNIT_REPORTS_TEST_OUTPUTS=() _BASHUNIT_REPORTS_CURRENT_OUTPUT="" function bashunit::reports::set_current_test_output() { _BASHUNIT_REPORTS_CURRENT_OUTPUT="$1" } 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" } function bashunit::reports::add_test_flaky() { bashunit::reports::add_test "$1" "$2" "$3" "$4" "flaky" "$5" "$6" } 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:-}" ] || [ -n "${BASHUNIT_REPORT_MD:-}" ] || bashunit::env::is_json_output_enabled || bashunit::env::is_junit_output_enabled || bashunit::env::should_append_step_summary || bashunit::env::should_print_gha_annotations } function bashunit::reports::add_test() { 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:-}" local retries="${7:-0}" local test_output="$_BASHUNIT_REPORTS_CURRENT_OUTPUT" _BASHUNIT_REPORTS_CURRENT_OUTPUT="" local line="" case "${_BASHUNIT_TEST_LOCATION:-}" in "$file":*) line="${_BASHUNIT_TEST_LOCATION##*:}" ;; esac if bashunit::parallel::is_enabled; then printf '%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \ "$(bashunit::helper::encode_base64 "$file")" \ "$(bashunit::helper::encode_base64 "$test_name")" \ "$(bashunit::helper::encode_base64 "$status")" \ "$(bashunit::helper::encode_base64 "$duration")" \ "$(bashunit::helper::encode_base64 "$assertions")" \ "$(bashunit::helper::encode_base64 "$failure_message")" \ "$(bashunit::helper::encode_base64 "$line")" \ "$(bashunit::helper::encode_base64 "$retries")" \ "$(bashunit::helper::encode_base64 "$test_output")" \ >>"${REPORTS_OUTPUT_PATH:-/dev/null}" 2>/dev/null || true fi _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" _BASHUNIT_REPORTS_TEST_RETRIES[${#_BASHUNIT_REPORTS_TEST_RETRIES[@]}]="$retries" _BASHUNIT_REPORTS_TEST_OUTPUTS[${#_BASHUNIT_REPORTS_TEST_OUTPUTS[@]}]="$test_output" } function bashunit::reports::load_spooled() { bashunit::reports::is_enabled || return 0 [ -f "${REPORTS_OUTPUT_PATH:-}" ] || return 0 local file test_name status duration assertions failure_message line retries test_output n while IFS='|' read -r file test_name status duration assertions failure_message line retries test_output; do [ -n "$file" ] || continue local n=${#_BASHUNIT_REPORTS_TEST_FILES[@]} _BASHUNIT_REPORTS_TEST_FILES[n]=$(bashunit::helper::decode_base64 "$file") _BASHUNIT_REPORTS_TEST_NAMES[n]=$(bashunit::helper::decode_base64 "$test_name") _BASHUNIT_REPORTS_TEST_STATUSES[n]=$(bashunit::helper::decode_base64 "$status") _BASHUNIT_REPORTS_TEST_DURATIONS[n]=$(bashunit::helper::decode_base64 "$duration") _BASHUNIT_REPORTS_TEST_ASSERTIONS[n]=$(bashunit::helper::decode_base64 "$assertions") _BASHUNIT_REPORTS_TEST_FAILURES[n]=$(bashunit::helper::decode_base64 "$failure_message") _BASHUNIT_REPORTS_TEST_LINES[n]=$(bashunit::helper::decode_base64 "$line") _BASHUNIT_REPORTS_TEST_RETRIES[n]=$(bashunit::helper::decode_base64 "$retries") _BASHUNIT_REPORTS_TEST_OUTPUTS[n]=$(bashunit::helper::decode_base64 "$test_output") done <"$REPORTS_OUTPUT_PATH" } # src/reports/junit.sh function bashunit::reports::__xml_escape() { local text="$1" bashunit::reports::__strip_ansi "$text" | tr -d '\000-\010\013\014\016-\037' | sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' -e "s/'/\'/g" } _BASHUNIT_REPORTS_MS_TO_S_OUT="" function bashunit::reports::__ms_to_s() { local ms="${1:-0}" case "$ms" in '' | *[!0-9]*) ms=0 ;; esac _BASHUNIT_REPORTS_MS_TO_S_OUT="$((ms / 1000)).$(printf '%03d' "$((ms % 1000))")" } _BASHUNIT_REPORTS_CLASSNAME_OUT="" function bashunit::reports::__junit_classname() { local path="$1" path="${path#./}" path="${path%.sh}" _BASHUNIT_REPORTS_CLASSNAME_OUT="${path//\//.}" } function bashunit::reports::generate_junit_xml() { bashunit::reports::print_junit_xml >"$1" } function bashunit::reports::print_junit_xml() { local timestamp timestamp=$(date '+%Y-%m-%dT%H:%M:%S') local suite_files suite_tests suite_failures suite_skipped suite_time cases suite_files=() suite_tests=() suite_failures=() suite_skipped=() suite_time=() cases=() local total_tests="${#_BASHUNIT_REPORTS_TEST_NAMES[@]}" local total_failures=0 local total_skipped=0 local total_time_ms=0 local i j s 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 duration_ms="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-0}" local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}" local test_output="${_BASHUNIT_REPORTS_TEST_OUTPUTS[$i]:-}" case "$duration_ms" in '' | *[!0-9]*) duration_ms=0 ;; esac s=-1 for j in ${suite_files[@]+"${!suite_files[@]}"}; do if [ "${suite_files[$j]}" = "$file" ]; then s=$j break fi done if [ "$s" -eq -1 ]; then s=${#suite_files[@]} suite_files[s]="$file" suite_tests[s]=0 suite_failures[s]=0 suite_skipped[s]=0 suite_time[s]=0 cases[s]="" fi suite_tests[s]=$((suite_tests[s] + 1)) suite_time[s]=$((suite_time[s] + duration_ms)) total_time_ms=$((total_time_ms + duration_ms)) local test_time escaped_name classname bashunit::reports::__ms_to_s "$duration_ms" test_time=$_BASHUNIT_REPORTS_MS_TO_S_OUT escaped_name=$(bashunit::reports::__xml_escape "$name") bashunit::reports::__junit_classname "$file" classname=$_BASHUNIT_REPORTS_CLASSNAME_OUT local case_xml=" " if [ "$status" = "failed" ]; then suite_failures[s]=$((suite_failures[s] + 1)) total_failures=$((total_failures + 1)) local escaped_message plain_message msg_head escaped_message=$(bashunit::reports::__xml_escape "$failure_message") plain_message=$(bashunit::reports::__strip_ansi "$failure_message") msg_head="${plain_message%%$'\n'*}" case "$msg_head" in '✗ Failed:'* | '✗ Error:'*) local msg_rest="${plain_message#*$'\n'}" if [ "$msg_rest" != "$plain_message" ]; then msg_head="${msg_rest%%$'\n'*}" msg_head="${msg_head#"${msg_head%%[![:space:]]*}"}" fi ;; esac local first_line first_line=$(bashunit::reports::__xml_escape "$msg_head") case_xml="$case_xml $escaped_message " elif [ "$status" = "flaky" ]; then local escaped_flaky escaped_flaky=$(bashunit::reports::__xml_escape "$failure_message") case_xml="$case_xml $escaped_flaky " elif [ "$status" = "risky" ]; then suite_skipped[s]=$((suite_skipped[s] + 1)) total_skipped=$((total_skipped + 1)) case_xml="$case_xml " elif [ "$status" = "skipped" ]; then suite_skipped[s]=$((suite_skipped[s] + 1)) total_skipped=$((total_skipped + 1)) case_xml="$case_xml " elif [ "$status" = "incomplete" ]; then suite_skipped[s]=$((suite_skipped[s] + 1)) total_skipped=$((total_skipped + 1)) case_xml="$case_xml " fi if [ -n "$test_output" ]; then local escaped_output escaped_output=$(bashunit::reports::__xml_escape "$test_output") case_xml="$case_xml $escaped_output " fi cases[s]="${cases[s]}$case_xml " done local total_time bashunit::reports::__ms_to_s "$total_time_ms" total_time=$_BASHUNIT_REPORTS_MS_TO_S_OUT { echo "" echo "" for s in ${suite_files[@]+"${!suite_files[@]}"}; do local suite_time_s bashunit::reports::__ms_to_s "${suite_time[$s]}" suite_time_s=$_BASHUNIT_REPORTS_MS_TO_S_OUT echo " " printf '%s' "${cases[$s]}" echo " " done echo "" } } # src/reports/tap.sh function bashunit::reports::__tap_message() { bashunit::reports::__strip_ansi "$1" | tr '\n' ' ' | sed -e "s/'/''/g" } function bashunit::reports::__tap_description() { local text="$1" text="${text//\\/\\\\}" printf '%s' "${text//[#]/\\#}" } 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 name="$(bashunit::reports::__tap_description "${_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" ;; flaky) echo "ok $seq - $name # TODO flaky (retried ${_BASHUNIT_REPORTS_TEST_RETRIES[$i]:-0}/${BASHUNIT_RETRY:-0})" ;; *) echo "ok $seq - $name" ;; esac done } >"$output_file" } # src/reports/json.sh function bashunit::reports::__json_escape() { local text="$1" text=$(bashunit::reports::__strip_ansi "$text" | tr -d '\000-\010\013\014\016-\037') text="${text//\\/\\\\}" text="${text//\"/\\\"}" text="${text//$'\t'/\\t}" text="${text//$'\r'/\\r}" text="${text//$'\n'/\\n}" printf '%s' "$text" } function bashunit::reports::generate_report_json() { bashunit::reports::print_report_json >"$1" } function bashunit::reports::print_report_json() { local total="${#_BASHUNIT_REPORTS_TEST_NAMES[@]}" local passed=0 failed=0 skipped=0 incomplete=0 flaky=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)) ;; flaky) flaky=$((flaky + 1)) passed=$((passed + 1)) ;; *) passed=$((passed + 1)) ;; esac done { printf '{\n' printf ' "summary": { "total": %d, "passed": %d, "failed": %d,' \ "$total" "$passed" "$failed" printf ' "skipped": %d, "incomplete": %d, "flaky": %d, "duration_ms": %d },\n' \ "$skipped" "$incomplete" "$flaky" "$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,' \ "$file" "$name" "$status" "$duration" printf ' "retries": %d, "message": "%s" }%s\n' \ "${_BASHUNIT_REPORTS_TEST_RETRIES[$i]:-0}" "$message" "$sep" seq=$((seq + 1)) done printf ' ]\n' printf '}\n' } } # src/reports/gha.sh function bashunit::reports::__gha_encode() { local text="$1" text=$(bashunit::reports::__strip_ansi "$text") text="${text//[%]/%25}" text="${text//$'\r'/%0D}" text="${text//$'\n'/%0A}" printf '%s' "$text" } 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)" ;; flaky) level="warning" message="Test passed only after ${_BASHUNIT_REPORTS_TEST_RETRIES[$i]:-0} retries: $failure_message" ;; 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" } # src/reports/html.sh 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 tests_risky=$(bashunit::state::get_tests_risky) local tests_flaky=$(bashunit::state::get_tests_flaky) local time=$(bashunit::clock::total_runtime_in_milliseconds) local temp_file temp_file=$(mktemp "${TMPDIR:-/tmp}/bashunit-report.XXXXXX") local _us _us=$(printf '\037') : >"$temp_file" 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$_us$name$_us$status$_us$test_time" echo "$test_case" >>"$temp_file" done local escaped_file escaped_file=$(mktemp "${TMPDIR:-/tmp}/bashunit-report-esc.XXXXXX") awk -v FS="$_us" -v OFS="$_us" '{ for (i = 1; i <= NF; i++) { gsub(/&/, "\\&", $i) gsub(//, "\\>", $i) gsub(/"/, "\\"", $i) } print }' "$temp_file" >"$escaped_file" && mv "$escaped_file" "$temp_file" { 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 " " echo " " echo " " echo " " echo "
Total TestsPassedFailedIncompleteSkippedSnapshotRiskyFlakyTime (ms)
${#_BASHUNIT_REPORTS_TEST_NAMES[@]}$test_passed$tests_failed$tests_incomplete$tests_skipped$tests_snapshot$tests_risky$tests_flaky$time
" echo "

Time: $time ms

" local current_file="" local file name status test_time while IFS="$_us" 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" if [ -n "$current_file" ]; then echo " " echo "
Test NameStatusTime (ms)
$name$status$test_time
" fi local any_failure=false local j for j in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do case "${_BASHUNIT_REPORTS_TEST_STATUSES[$j]:-}" in failed) ;; *) continue ;; esac if [ "$any_failure" = false ]; then echo "

Failures

" any_failure=true fi local f_name f_file f_line f_message f_name=$(bashunit::str::html_escape "${_BASHUNIT_REPORTS_TEST_NAMES[$j]:-}") f_file=$(bashunit::str::html_escape "${_BASHUNIT_REPORTS_TEST_FILES[$j]:-}") f_line="${_BASHUNIT_REPORTS_TEST_LINES[$j]:-}" f_message=$(bashunit::str::html_escape \ "$(bashunit::reports::__strip_ansi "${_BASHUNIT_REPORTS_TEST_FAILURES[$j]:-}")") echo "

$f_name

" if [ -n "$f_line" ]; then echo "

$f_file:$f_line

" else echo "

$f_file

" fi echo "
$f_message
" done echo "" echo "" } >"$output_file" rm -f "$temp_file" } # src/reports/markdown.sh function bashunit::reports::__md_escape() { local text="$1" text=$(bashunit::reports::__strip_ansi "$text") text="${text//\\/\\\\}" text="${text//|/\\|}" text="${text//\`/\\\`}" text="${text//\*/\\*}" text="${text//_/\\_}" printf '%s' "$text" } function bashunit::reports::__md_count_row() { local label=$1 local count=$2 if [ "${count:-0}" -gt 0 ]; then printf '| %s | %s |\n' "$label" "$count" fi } function bashunit::reports::print_report_md() { local passed failed skipped incomplete snapshot risky flaky duration_ms passed=$(bashunit::state::get_tests_passed) failed=$(bashunit::state::get_tests_failed) skipped=$(bashunit::state::get_tests_skipped) incomplete=$(bashunit::state::get_tests_incomplete) snapshot=$(bashunit::state::get_tests_snapshot) risky=$(bashunit::state::get_tests_risky) flaky=$(bashunit::state::get_tests_flaky) duration_ms=$(bashunit::clock::total_runtime_in_milliseconds) local duration duration=$(bashunit::console_results::format_duration "$duration_ms") echo "## bashunit" echo "" if [ "${failed:-0}" -gt 0 ]; then printf '❌ **%s failed**, %s passed in %s\n' "$failed" "$passed" "$duration" else printf '✅ **%s passed** in %s\n' "$passed" "$duration" fi echo "" echo "| Result | Count |" echo "|--------|-------|" printf '| Passed | %s |\n' "$passed" printf '| Failed | %s |\n' "$failed" bashunit::reports::__md_count_row "Skipped" "$skipped" bashunit::reports::__md_count_row "Incomplete" "$incomplete" bashunit::reports::__md_count_row "Snapshot" "$snapshot" bashunit::reports::__md_count_row "Risky" "$risky" bashunit::reports::__md_count_row "Flaky" "$flaky" echo "" bashunit::reports::__md_failures bashunit::reports::__md_coverage bashunit::reports::__md_profile } function bashunit::reports::__md_failures() { local i any=false for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do case "${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" in failed) ;; *) continue ;; esac if [ "$any" = false ]; then echo "## Failures" echo "" any=true fi local name file line message name=$(bashunit::reports::__md_escape "${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}") file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}" line="${_BASHUNIT_REPORTS_TEST_LINES[$i]:-}" message=$(bashunit::reports::__strip_ansi "${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}") printf '### %s\n\n' "$name" if [ -n "$line" ]; then printf '`%s:%s`\n\n' "$file" "$line" else printf '`%s`\n\n' "$file" fi echo '```' printf '%s\n' "$message" echo '```' echo "" done } function bashunit::reports::__md_coverage() { if [ "${_BASHUNIT_COVERAGE_ON:-0}" != 1 ]; then return 0 fi local pct pct=$(bashunit::coverage::get_percentage 2>/dev/null) || return 0 [ -n "$pct" ] || return 0 echo "## Coverage" echo "" printf '%s%% of tracked lines\n' "$pct" echo "" } function bashunit::reports::__md_profile() { if ! bashunit::env::is_profile_enabled; then return 0 fi [ -s "${PROFILE_OUTPUT_PATH:-}" ] || return 0 echo "## Slowest tests" echo "" echo "| Duration | Test | File |" echo "|----------|------|------|" local duration name file formatted while IFS=$'\t' read -r duration name file; do formatted=$(bashunit::console_results::format_duration "$duration") printf '| %s | %s | %s |\n' \ "$formatted" \ "$(bashunit::reports::__md_escape "$name")" \ "$(bashunit::reports::__md_escape "$file")" done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "${BASHUNIT_PROFILE_COUNT:-10}") echo "" } function bashunit::reports::generate_report_md() { local output_file="$1" bashunit::reports::print_report_md >"$output_file" } function bashunit::reports::append_step_summary() { [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 bashunit::reports::print_report_md >>"$GITHUB_STEP_SUMMARY" } # src/runner/index.sh # src/runner/context.sh function bashunit::runner::restore_workdir() { local target="${1:-${BASHUNIT_WORKING_DIR:-}}" if cd "$target" 2>/dev/null; then return 0 fi printf "%sError: cannot restore the working directory '%s'. Aborting run.%s\n" \ "${_BASHUNIT_COLOR_FAILED:-}" "$target" "${_BASHUNIT_COLOR_DEFAULT:-}" >&2 exit 1 } 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 ] } 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() { [ -f /etc/profile ] && source /etc/profile 2>/dev/null || true [ -f ~/.bash_profile ] && source ~/.bash_profile 2>/dev/null || true [ -f ~/.bash_login ] && source ~/.bash_login 2>/dev/null || true [ -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 } function bashunit::runner::resolve_test_location() { local test_file=$1 local fn_name=$2 local def line="" def="$( shopt -s extdebug declare -F "$fn_name" 2>/dev/null )" || true 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 } function bashunit::runner::apply_interpolated_title() { local fn_name=$1 shift 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 } 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 } # src/runner/sandbox.sh _BASHUNIT_SANDBOX_BASELINE="awk sed grep cat cut tr sort uniq head tail wc date mktemp mkdir rm rmdir mv cp ln touch chmod stat find dirname basename base64 cksum od diff sleep env printf test true false expr id getconf bc perl python3 uname tput git kill ps sh bash" _BASHUNIT_SANDBOX_DIR="" _BASHUNIT_SANDBOX_VIOLATION_FILE="" _BASHUNIT_SANDBOX_VIOLATION_SEQ=0 _BASHUNIT_SANDBOX_ALLOWED="" _BASHUNIT_SANDBOX_BUILTINS="" function bashunit::sandbox::_allowed_list() { local allowed="$_BASHUNIT_SANDBOX_BASELINE" if [ -n "${BASHUNIT_SANDBOX_ALLOW:-}" ]; then allowed="$allowed $(printf '%s' "$BASHUNIT_SANDBOX_ALLOW" | tr ',' ' ')" fi printf ' %s ' "$(printf '%s' "$allowed" | tr '\n' ' ')" } function bashunit::sandbox::is_allowed() { case "$_BASHUNIT_SANDBOX_ALLOWED" in *" $1 "*) return 0 ;; esac return 1 } function bashunit::sandbox::shim() { local name=$1 case "$name" in '' | *[!A-Za-z0-9_.+-]*) return 0 ;; esac case "$_BASHUNIT_SANDBOX_BUILTINS" in *" $name "*) return 0 ;; esac bashunit::sandbox::is_allowed "$name" && return 0 eval "function $name() { bashunit::sandbox::blocked '$name'; }" } function bashunit::sandbox::restore_shim() { bashunit::env::is_sandbox_enabled || return 0 bashunit::sandbox::shim "$1" } function bashunit::sandbox::blocked() { local name=$1 if [ -n "$_BASHUNIT_SANDBOX_VIOLATION_FILE" ]; then printf '%s\n' "$name" >>"$_BASHUNIT_SANDBOX_VIOLATION_FILE" 2>/dev/null || true fi bashunit::sandbox::violation_message "$name" >&2 return 127 } function bashunit::sandbox::violation_message() { printf "Sandbox: '%s' is not mocked and not allowed. %s\n" \ "$1" "Mock it with bashunit::mock, or run with --sandbox-allow $1." } function bashunit::sandbox::prepare() { bashunit::env::is_sandbox_enabled || return 0 _BASHUNIT_SANDBOX_ALLOWED=$(bashunit::sandbox::_allowed_list) _BASHUNIT_SANDBOX_BUILTINS=" $(compgen -b | tr '\n' ' ') " local dir="${_BASHUNIT_RUN_OUTPUT_DIR:-${BASHUNIT_TEMP_DIR:-${TMPDIR:-/tmp}}}/sandbox" mkdir -p "$dir" 2>/dev/null || return 0 _BASHUNIT_SANDBOX_DIR="$dir" export _BASHUNIT_SANDBOX_DIR local link_allowed=true if bashunit::check_os::is_windows; then link_allowed=false fi local entry name target local path_dir local IFS=':' for path_dir in $PATH; do [ -n "$path_dir" ] || continue [ -d "$path_dir" ] || continue for entry in "$path_dir"/*; do if [ "$link_allowed" = true ]; then [ -f "$entry" ] || continue [ -x "$entry" ] || continue fi name=${entry##*/} if bashunit::sandbox::is_allowed "$name"; then if [ "$link_allowed" = true ] && [ ! -e "$dir/$name" ]; then ln -s "$entry" "$dir/$name" 2>/dev/null || true fi continue fi bashunit::sandbox::shim "$name" done done [ "$link_allowed" = true ] || return 0 unset IFS for name in $_BASHUNIT_SANDBOX_ALLOWED; do [ -e "$dir/$name" ] && continue target=$(command -v "$name" 2>/dev/null) || continue case "$target" in /*) ln -s "$target" "$dir/$name" 2>/dev/null || true ;; esac done } function bashunit::sandbox::activate() { bashunit::env::is_sandbox_enabled || return 0 [ -n "$_BASHUNIT_SANDBOX_DIR" ] || return 0 if bashunit::check_os::is_windows; then return 0 fi PATH="$_BASHUNIT_SANDBOX_DIR" export PATH } function bashunit::sandbox::begin_test() { bashunit::env::is_sandbox_enabled || return 0 [ -n "$_BASHUNIT_SANDBOX_DIR" ] || return 0 _BASHUNIT_SANDBOX_VIOLATION_SEQ=$((_BASHUNIT_SANDBOX_VIOLATION_SEQ + 1)) _BASHUNIT_SANDBOX_VIOLATION_FILE="$_BASHUNIT_SANDBOX_DIR/violation-$$-$_BASHUNIT_SANDBOX_VIOLATION_SEQ" export _BASHUNIT_SANDBOX_VIOLATION_FILE rm -f "$_BASHUNIT_SANDBOX_VIOLATION_FILE" 2>/dev/null || true } function bashunit::sandbox::peek_violation() { bashunit::env::is_sandbox_enabled || return 1 [ -n "$_BASHUNIT_SANDBOX_VIOLATION_FILE" ] || return 1 [ -s "$_BASHUNIT_SANDBOX_VIOLATION_FILE" ] } _BASHUNIT_SANDBOX_COMMAND_OUT="" function bashunit::sandbox::violation_of_test() { _BASHUNIT_SANDBOX_COMMAND_OUT="" bashunit::env::is_sandbox_enabled || return 1 [ -n "$_BASHUNIT_SANDBOX_VIOLATION_FILE" ] || return 1 [ -s "$_BASHUNIT_SANDBOX_VIOLATION_FILE" ] || return 1 local first="" while IFS= read -r first; do [ -n "$first" ] && break done <"$_BASHUNIT_SANDBOX_VIOLATION_FILE" rm -f "$_BASHUNIT_SANDBOX_VIOLATION_FILE" 2>/dev/null || true [ -n "$first" ] || return 1 _BASHUNIT_SANDBOX_COMMAND_OUT=$first return 0 } # src/runner/payload.sh _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="" _BASHUNIT_RUNNER_RESULT_ORDINAL=0 _BASHUNIT_RETRY_NOTE="" 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 } 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%%##*}" case "$failed$passed$skipped$incomplete$snapshot" in *[!0-9]*) failed=0 passed=0 skipped=0 incomplete=0 snapshot=0 ;; esac local total total=$((failed + passed + skipped)) total=$((total + incomplete + snapshot)) _BASHUNIT_RUNNER_TOTAL_OUT=$total } function bashunit::runner::extract_subshell_type() { local subshell_output=$1 local type="${subshell_output%%]*}" _BASHUNIT_RUNNER_TYPE_OUT="${type#[}" } 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 } 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_BASE64_EMPTY_SENTINEL" ]; 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::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 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%%##*}" 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 } # src/runner/diagnostics.sh 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" } function bashunit::runner::_scan_diagnostic_lines() { local runtime_output=$1 local line while IFS= read -r line; do case "$line" in *": line "[0-9]*": "*) ;; *) continue ;; esac case "$line" in *"command not found"* | *"unbound variable"* | *"permission denied"* | \ *"no such file or directory"* | *"syntax error"* | *"bad substitution"* | \ *"division by 0"* | *"bad file descriptor"* | \ *"illegal option"* | *"argument list too long"* | \ *"readonly variable"* | *"missing keyword"* | \ *"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'/}" return ;; esac done </dev/null || break bashunit::runner::_count_running_jobs done return 0 fi 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::spinner() { if [ ! -t 1 ]; then while true; do sleep 1; done return fi if bashunit::env::is_no_progress_enabled || bashunit::env::is_machine_output_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 } # src/runner/hooks.sh function bashunit::runner::cleanup_on_exit() { local test_file="$1" local exit_code="$2" if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::disable_trap fi set +e bashunit::assert::once_flush 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 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 if bashunit::sandbox::peek_violation; then bashunit::state::set_test_exit_code 127 fi bashunit::state::export_subshell_context } 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") _BASHUNIT_HOOK_ERR_STATUS=0 set -E if bashunit::env::is_strict_mode_enabled; then set -uo pipefail fi 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 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" if ! declare -F "set_up_before_script" >/dev/null 2>&1; then return 0 fi local start_time start_time=$(bashunit::clock::now) if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::enable_trap fi bashunit::runner::execute_file_hook 'set_up_before_script' "$test_file" false local status=$? 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)) 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") _BASHUNIT_HOOK_ERR_STATUS=0 set -E if bashunit::env::is_strict_mode_enabled; then set -uo pipefail fi 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 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" if ! declare -F "tear_down_after_script" >/dev/null 2>&1; then if ! bashunit::env::is_simple_output_enabled && ! bashunit::env::is_failures_only_enabled && ! bashunit::env::is_no_progress_enabled && ! bashunit::env::is_json_output_enabled && ! bashunit::env::is_junit_output_enabled && ! bashunit::parallel::is_enabled; then echo "" fi return 0 fi local start_time start_time=$(bashunit::clock::now) if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::enable_trap fi bashunit::runner::execute_file_hook 'tear_down_after_script' "$test_file" local status=$? 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)) if [ $status -eq 0 ]; then bashunit::console_results::print_hook_completed "tear_down_after_script" "$duration_ms" fi 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 } 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' } # src/runner/result.sh 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 } _BASHUNIT_RUNNER_SUITE_DIR_OUT="" function bashunit::runner::parallel_suite_dir_to_slot() { local key="${1#./}" key="${key%.sh}" key="${key//\//_}" _BASHUNIT_RUNNER_SUITE_DIR_OUT="${TEMP_DIR_PARALLEL_TEST_SUITE}/${key}" } function bashunit::runner::parse_result_parallel() { local fn_name=$1 shift local execution_result=$1 bashunit::runner::parallel_suite_dir_to_slot "$test_file" local test_suite_dir=$_BASHUNIT_RUNNER_SUITE_DIR_OUT [ -d "$test_suite_dir" ] || mkdir -p "$test_suite_dir" local unique_test_result_file="${test_suite_dir}/${_BASHUNIT_RUNNER_RESULT_ORDINAL}.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" } 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 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)) if [ "$line_num" -le "$fn_line" ]; then continue fi stripped="${line_text#"${line_text%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" if [ "$stripped" = "}" ]; then break fi 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" } # src/runner/provider.sh function bashunit::runner::parse_data_provider_args() { local input="$1" local current_arg="" local in_quotes=false local had_quotes=false local quote_char="" local escaped=false local IFS=$' \t\n' local i=0 local arg="" local encoded_arg local -a args=() local args_count=0 local has_metachar=false if [ "$(echo "$input" | "$GREP" -cE '(^|[^\])[|&;*]' || true)" -gt 0 ]; then has_metachar=true fi local trailing="${input##*[!\\]}" if [ $((${#trailing} % 2)) -eq 1 ]; then has_metachar=true fi if [ "$has_metachar" = false ] && eval "args=($input)" 2>/dev/null; then args_count=0 local _tmp arg for _tmp in ${args+"${args[@]}"}; do args_count=$((args_count + 1)); done if [ "$args_count" -gt 0 ]; then local last_idx=$((args_count - 1)) if [ -z "${args[$last_idx]}" ]; then unset 'args[$last_idx]' fi for arg in "${args[@]+"${args[@]}"}"; do encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" done return fi fi 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 "$") if [ "${input:$i:2}" = "$'" ]; then in_quotes=true had_quotes=true quote_char="'" i=$((i + 1)) else current_arg="$current_arg$char" fi ;; "'" | '"') in_quotes=true had_quotes=true quote_char="$char" ;; " " | $'\t') 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)) 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 local arg for arg in ${args+"${args[@]}"}; do encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" done } # src/runner/exec.sh _BASHUNIT_RUNNER_ORDERED_FNS_OUT="" function bashunit::runner::order_functions_for_script() { local script="$1" local fns="${2:-}" local IFS=$' \t\n' local -a ordered=() local fn for fn in $fns; do [ -z "$fn" ] && continue ordered[${#ordered[@]}]="$fn" done if bashunit::env::is_defects_order_enabled && [ "${#ordered[@]}" -gt 1 ]; then local -a _defect_fns=() local _defect_fn for _defect_fn in $(bashunit::rerun::order_functions "$script" "${ordered[*]+${ordered[*]}}"); do _defect_fns[${#_defect_fns[@]}]=$_defect_fn done ordered=("${_defect_fns[@]+"${_defect_fns[@]}"}") fi if bashunit::env::is_random_order_enabled && [ "${#ordered[@]}" -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' "${ordered[@]+"${ordered[@]}"}" | bashunit::math::shuffle "$_fn_seed") ordered=("${_shuffled_fns[@]+"${_shuffled_fns[@]}"}") fi _BASHUNIT_RUNNER_ORDERED_FNS_OUT="${ordered[*]+${ordered[*]}}" } function bashunit::runner::report_unusable_provider() { local test_file="$1" local fn_name="$2" local provider="$3" local reason if declare -F "$provider" >/dev/null 2>&1; then reason="data provider '$provider' produced no data, so the test never ran" else reason="data provider '$provider' is not defined, so the test never ran" fi bashunit::state::add_tests_failed bashunit::console_results::print_error_test "$fn_name" "$reason" local _normalized_fn _normalized_fn="$(bashunit::helper::normalize_test_function_name "$fn_name")" bashunit::reports::add_test_failed "$test_file" "$_normalized_fn" 0 0 "$reason" bashunit::runner::write_failure_result_output "$test_file" "$fn_name" "$reason" if bashunit::parallel::is_enabled; then bashunit::runner::parallel_suite_dir_to_slot "$test_file" local suite_dir=$_BASHUNIT_RUNNER_SUITE_DIR_OUT [ -d "$suite_dir" ] || mkdir -p "$suite_dir" local payload="##ASSERTIONS_FAILED=0##ASSERTIONS_PASSED=0##ASSERTIONS_SKIPPED=0" payload="$payload##ASSERTIONS_INCOMPLETE=0##ASSERTIONS_SNAPSHOT=0##TEST_EXIT_CODE=1##" printf '%s\n' "$payload" \ >"${suite_dir}/${_BASHUNIT_RUNNER_RESULT_ORDINAL}.result" fi } function bashunit::runner::call_test_functions() { local script="$1" local cached_functions="${2:-}" local IFS=$' \t\n' local -a functions_to_run=() local functions_to_run_count=0 bashunit::runner::order_functions_for_script "$script" "$cached_functions" local _ofn for _ofn in $_BASHUNIT_RUNNER_ORDERED_FNS_OUT; do functions_to_run[functions_to_run_count]="$_ofn" functions_to_run_count=$((functions_to_run_count + 1)) done if [ "$functions_to_run_count" -le 0 ]; then return fi local -a provider_data=() local provider_data_count=0 local -a parsed_data=() local parsed_data_count=0 local _test_ordinal=0 bashunit::helper::build_provider_map "$script" bashunit::helper::annotations_validate_or_exit "$script" local allow_test_parallel=true if [ "$_BASHUNIT_PROVIDER_MAP_NO_PARALLEL" = true ]; then allow_test_parallel=false fi if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then bashunit::runner::parallel_suite_dir_to_slot "$script" mkdir -p "$_BASHUNIT_RUNNER_SUITE_DIR_OUT" 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 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 _test_ordinal=$((_test_ordinal + 1)) _BASHUNIT_RUNNER_RESULT_ORDINAL=$_test_ordinal 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")" if [ "$provider_data_count" -eq 0 ]; then _test_ordinal=$((_test_ordinal + 1)) _BASHUNIT_RUNNER_RESULT_ORDINAL=$_test_ordinal bashunit::runner::report_unusable_provider \ "$script" "$fn_name" "$_BASHUNIT_PROVIDER_FN_OUT" unset -v fn_name continue fi 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 _test_ordinal=$((_test_ordinal + 1)) _BASHUNIT_RUNNER_RESULT_ORDINAL=$_test_ordinal 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 if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then wait fi } _BASHUNIT_RUNNER_EXEC_OUT="" _BASHUNIT_RUNNER_TIMED_OUT="false" _BASHUNIT_RUNNER_TIMEOUT_SECS=0 function bashunit::runner::execute_test_body() { local test_file=$1 shift local fn_name=$1 shift exec 5>&1 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 if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then bashunit::coverage::enable_trap fi bashunit::helper::annotations_for_function "$fn_name" if [ "$_BASHUNIT_ANNOT_SKIP_OUT" = true ]; then bashunit::helper::normalize_test_function_name_to_slot "$fn_name" bashunit::skip::__mark_with_label \ "$_BASHUNIT_HELPER_NORMALIZED_OUT" "$_BASHUNIT_ANNOT_REASON_OUT" exit 0 fi bashunit::sandbox::activate _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 if bashunit::env::is_strict_mode_enabled; then set -eu if bashunit::runner::_supports_reliable_pipefail; then set -o pipefail else set +o pipefail fi else set +euo pipefail fi "$fn_name" "$@" 2>&1 } 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=##" } function bashunit::runner::run_with_timeout() { local test_file=$1 shift local fn_name=$1 shift local secs=$_BASHUNIT_RUNNER_TIMEOUT_SECS 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" set -m (bashunit::runner::execute_test_body "$test_file" "$fn_name" "$@") >"$out_file" 2>&1 & local test_pid=$! ( sleep "$secs" 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 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" } 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" exec 3>&1 local test_execution_result local timed_out="false" bashunit::helper::annotations_for_function "$fn_name" if bashunit::env::is_test_timeout_enabled; then _BASHUNIT_RUNNER_TIMEOUT_SECS=${BASHUNIT_TEST_TIMEOUT:-0} else _BASHUNIT_RUNNER_TIMEOUT_SECS=0 fi if [ -n "$_BASHUNIT_ANNOT_TIMEOUT_OUT" ]; then _BASHUNIT_RUNNER_TIMEOUT_SECS=$_BASHUNIT_ANNOT_TIMEOUT_OUT fi bashunit::sandbox::begin_test bashunit::env::resolve_retry_count local retry_max=$_BASHUNIT_RETRY_VALIDATED if [ -n "$_BASHUNIT_ANNOT_RETRY_OUT" ]; then retry_max=$_BASHUNIT_ANNOT_RETRY_OUT fi local retries_used=0 local first_attempt_result="" bashunit::env::resolve_repeat_count local repeat_max=$_BASHUNIT_REPEAT_VALIDATED local iteration=0 local failed_iteration=0 local measure_duration=false bashunit::runner::needs_test_duration && measure_duration=true while [ "$iteration" -lt "$repeat_max" ]; do iteration=$((iteration + 1)) retries_used=0 first_attempt_result="" while :; do if [ "$measure_duration" = true ]; then bashunit::clock::now_to_slot start_time=$_BASHUNIT_CLOCK_NOW_OUT fi if [ "$_BASHUNIT_RUNNER_TIMEOUT_SECS" -gt 0 ]; 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::extract_result_counts "$test_execution_result" bashunit::runner::detect_runtime_error "$attempt_runtime_output" \ "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" local attempt_runtime_error=$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT local attempt_display_output=$_BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT if [ -z "$attempt_runtime_error" ] && [ "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" -eq 0 ] && [ "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" -eq 0 ]; then break fi if [ -z "$first_attempt_result" ]; then first_attempt_result="$test_execution_result" fi [ "$retries_used" -ge "$retry_max" ] && break retries_used=$((retries_used + 1)) done if [ -n "$attempt_runtime_error" ] || [ "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" -ne 0 ] || [ "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" -ne 0 ]; then failed_iteration=$iteration break fi done test_execution_result="$test_execution_result##TEST_RETRIES=$retries_used##" 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::console_results::print_line "$type" "$subshell_output" fi fi local runtime_output=$attempt_display_output local runtime_error=$attempt_runtime_error bashunit::reports::set_current_test_output "$runtime_output" _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 repeat_note="" if [ "$repeat_max" -gt 1 ] && [ "$failed_iteration" -gt 0 ]; then repeat_note=" (failed on iteration $failed_iteration of $repeat_max)" fi 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 local exit_code_is_unexplained=false if [ "$test_exit_code" -ne 0 ] && [ "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" -eq 0 ]; then exit_code_is_unexplained=true fi if [ -n "$runtime_error" ] || [ "$exit_code_is_unexplained" = true ]; 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 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 if [ "$timed_out" = "true" ]; then error_message="Test timed out after ${_BASHUNIT_RUNNER_TIMEOUT_SECS}s" fi error_message="$error_message$repeat_note" 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" bashunit::runner::halt_if_stop_on_failure 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$repeat_note" if [ -n "$repeat_note" ]; then bashunit::console_results::print_line "failed" "${repeat_note# }" fi 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" bashunit::runner::halt_if_stop_on_failure return fi if [ "$current_assertions_snapshot" != "$_BASHUNIT_ASSERTIONS_SNAPSHOT" ]; then bashunit::state::add_tests_snapshot 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 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" bashunit::runner::halt_if_stop_on_failure 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 _BASHUNIT_RETRY_NOTE="" if [ "$retries_used" -gt 0 ]; then _BASHUNIT_RETRY_NOTE=" (retry $retries_used/$retry_max)" fi 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 if [ "$retries_used" -gt 0 ]; then bashunit::state::add_tests_flaky bashunit::runner::decode_subshell_output "$first_attempt_result" local first_failure=$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT bashunit::runner::format_subshell_output "$first_failure" first_failure=$_BASHUNIT_RUNNER_OUTPUT_OUT bashunit::reports::add_test_flaky \ "$test_file" "$label" "$duration" "$total_assertions" "$first_failure" "$retries_used" bashunit::internal_log "Test flaky" "$label" "retries:$retries_used" return fi bashunit::reports::add_test_passed "$test_file" "$label" "$duration" "$total_assertions" bashunit::internal_log "Test passed" "$label" } # src/runner/list.sh _BASHUNIT_LIST_JSON_ITEMS="" _BASHUNIT_LIST_COUNT=0 function bashunit::runner::list_reset() { _BASHUNIT_LIST_JSON_ITEMS="" _BASHUNIT_LIST_COUNT=0 } function bashunit::runner::list_functions() { local script="$1" local fns="${2:-}" local IFS=$' \t\n' bashunit::runner::order_functions_for_script "$script" "$fns" local wants_json=false if [ "$BASHUNIT_LIST_FORMAT" = "json" ]; then wants_json=true bashunit::helper::build_tags_map "$script" fi local fn for fn in $_BASHUNIT_RUNNER_ORDERED_FNS_OUT; do [ -z "$fn" ] && continue _BASHUNIT_LIST_COUNT=$((_BASHUNIT_LIST_COUNT + 1)) if [ "$wants_json" = false ]; then printf '%s::%s\n' "$script" "$fn" continue fi local name line tags tags_json tag name=$(bashunit::helper::normalize_test_function_name "$fn") line=$(bashunit::helper::get_function_line_number "$fn") bashunit::helper::tags_for_function "$fn" tags="$_BASHUNIT_TAGS_OUT" tags_json="" local old_ifs="$IFS" IFS=',' local -a tag_list=() for tag in $tags; do tag_list[${#tag_list[@]}]="$tag" done IFS="$old_ifs" for tag in ${tag_list[@]+"${tag_list[@]}"}; do [ -z "$tag" ] && continue [ -n "$tags_json" ] && tags_json="$tags_json," tags_json="$tags_json\"$(bashunit::reports::__json_escape "$tag")\"" done local item item="{\"file\":\"$(bashunit::reports::__json_escape "$script")\"" item="$item,\"function\":\"$(bashunit::reports::__json_escape "$fn")\"" item="$item,\"name\":\"$(bashunit::reports::__json_escape "$name")\"" item="$item,\"line\":${line:-0}" item="$item,\"tags\":[$tags_json]}" [ -n "$_BASHUNIT_LIST_JSON_ITEMS" ] && _BASHUNIT_LIST_JSON_ITEMS="$_BASHUNIT_LIST_JSON_ITEMS," _BASHUNIT_LIST_JSON_ITEMS="$_BASHUNIT_LIST_JSON_ITEMS$item" done } function bashunit::runner::list_render_summary() { if [ "$BASHUNIT_LIST_FORMAT" = "json" ]; then printf '{"count":%s,"tests":[%s]}\n' \ "$_BASHUNIT_LIST_COUNT" "$_BASHUNIT_LIST_JSON_ITEMS" return 0 fi local unit="tests" [ "$_BASHUNIT_LIST_COUNT" -eq 1 ] && unit="test" printf '%s %s\n' "$_BASHUNIT_LIST_COUNT" "$unit" >&2 } # src/runner/discovery.sh function bashunit::runner::load_test_files() { local filter=$1 local tag_filter="${2:-}" local exclude_tag_filter="${3:-}" _BASHUNIT_ACTIVE_FILTER="$filter" shift 3 local IFS=$' \t\n' local -a files files=("$@") local -a scripts_ids=() local scripts_ids_count=0 local -a worker_stderr_paths=() local -a worker_stderr_owners=() local worker_stderr_count=0 if bashunit::env::is_defects_order_enabled; then bashunit::rerun::load local -a _defect_files=() local _defect_file while IFS= read -r _defect_file; do [ -n "$_defect_file" ] && _defect_files[${#_defect_files[@]}]=$_defect_file done < <(bashunit::rerun::order_files "${files[@]+"${files[@]}"}") files=("${_defect_files[@]+"${_defect_files[@]}"}") fi 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 if [ "$_BASHUNIT_COVERAGE_ON" = 1 ]; then if [ -z "$BASHUNIT_COVERAGE_PATHS" ]; then BASHUNIT_COVERAGE_PATHS=$(bashunit::coverage::auto_discover_paths "${files[@]}") 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" local source_err_file source_err source_status source_err_file="$_BASHUNIT_RUN_OUTPUT_DIR/source_err" if [ ! -d "$_BASHUNIT_RUN_OUTPUT_DIR" ]; then if [ "${_BASHUNIT_RUN_DIR_VANISHED:-false}" = false ]; then _BASHUNIT_RUN_DIR_VANISHED=true printf 'bashunit: the run scratch directory disappeared mid-run: %s\n' \ "$_BASHUNIT_RUN_OUTPUT_DIR" >&2 printf 'bashunit: recreating it; please report this with the run log (#1137).\n' >&2 fi if ! mkdir -p "$_BASHUNIT_RUN_OUTPUT_DIR" 2>/dev/null; then source_err_file=/dev/null fi fi source "$test_file" 2>"$source_err_file" source_status=$? set +euo pipefail source_err="" if [ -s "$source_err_file" ]; then source_err="$(cat "$source_err_file")" fi 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" if [ -z "$message" ]; then local source_bytes="unknown" if [ -f "$test_file" ]; then source_bytes=$(bashunit::io::file_size "$test_file") fi message="Failed to source '$test_file' (exit $source_status, $source_bytes bytes, no stderr)" fi 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 _BASHUNIT_CACHED_ALL_FUNCTIONS=$(compgen -A function) 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") local _script_fns_to_clean="$functions_for_script" 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 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 if bashunit::env::is_list_enabled; then bashunit::runner::list_functions "$test_file" "$functions_for_script" 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 bashunit::runner::render_running_file_header "$test_file" bashunit::runner::run_set_up_before_script "$test_file" local setup_before_script_status=$? if [ $setup_before_script_status -ne 0 ]; then if [ -n "$functions_for_script" ]; then local functions_to_run 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 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" bashunit::helper::check_duplicate_functions "$test_file" || true if bashunit::parallel::is_enabled; then bashunit::runner::wait_for_job_slot local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}" worker_stderr_paths[worker_stderr_count]="$_worker_stderr" worker_stderr_owners[worker_stderr_count]="$test_file" worker_stderr_count=$((worker_stderr_count + 1)) bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" & else bashunit::runner::call_test_functions "$test_file" "$_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::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" disown "$spinner_pid" 2>/dev/null || true kill "$spinner_pid" 2>/dev/null || true if [ -t 1 ] && ! bashunit::env::is_no_progress_enabled && ! bashunit::env::is_machine_output_enabled; then printf "\r \r" fi local _stderr_idx=0 while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then bashunit::console_results::print_worker_stderr \ "${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}" fi _stderr_idx=$((_stderr_idx + 1)) done 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::functions_for_script() { local script="$1" local all_fn_names="$2" local declarations declarations=$( shopt -s extdebug declare -F $all_fn_names 2>/dev/null ) 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 <&2 return 1 } 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 bashunit::benchmark::reject_malformed_marker "$annotation" "revs" "$_extracted" || return 1 bashunit::benchmark::reject_malformed_marker "$annotation" "revolutions" "$_extracted" || return 1 _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 bashunit::benchmark::reject_malformed_marker "$annotation" "its" "$_extracted" || return 1 bashunit::benchmark::reject_malformed_marker "$annotation" "iterations" "$_extracted" || return 1 _extracted=$(echo "$annotation" | sed -n 's/.*@max_ms=\([0-9.][0-9.]*\).*/\1/p') if [ -n "$_extracted" ]; then max_ms="$_extracted" fi bashunit::benchmark::reject_malformed_marker "$annotation" "max_ms" "$max_ms" || return 1 if [ -n "$max_ms" ]; then echo "$revs" "$its" "$max_ms" else echo "$revs" "$its" fi } # src/benchmark/run.sh function bashunit::benchmark::run_function() { local fn_name=$1 local revs=$2 local its=$3 local max_ms=$4 local bench_file=${5:-} 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::console_results::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[@]}") local joined="${durations[*]+${durations[*]}}" bashunit::benchmark::add_result "$fn_name" "$revs" "$its" "$avg" "$max_ms" \ "$bench_file" "$joined" } # src/benchmark/reports.sh _BASHUNIT_BENCH_STATS_MIN_OUT="" _BASHUNIT_BENCH_STATS_MAX_OUT="" _BASHUNIT_BENCH_STATS_MEDIAN_OUT="" function bashunit::benchmark::stats_to_slots() { local durations=$1 _BASHUNIT_BENCH_STATS_MIN_OUT="" _BASHUNIT_BENCH_STATS_MAX_OUT="" _BASHUNIT_BENCH_STATS_MEDIAN_OUT="" [ -n "$durations" ] || return 0 local stats stats=$(printf '%s\n' "$durations" | env LC_ALL=C awk ' { n = 0 for (i = 1; i <= NF; i++) { values[++n] = $i + 0 } if (n == 0) { exit } for (i = 2; i <= n; i++) { v = values[i] j = i - 1 while (j >= 1 && values[j] > v) { values[j + 1] = values[j]; j-- } values[j + 1] = v } if (n % 2) { median = values[(n + 1) / 2] } else { median = (values[n / 2] + values[n / 2 + 1]) / 2 } printf "%.3f %.3f %.3f\n", values[1], values[n], median } ') local min max median read -r min max median </dev/null || printf 'unknown') { printf '{\n' printf ' "run": {\n' printf ' "timestamp": "%s",\n' "$timestamp" printf ' "duration_ms": %s,\n' "$(bashunit::benchmark::_run_duration_ms)" printf ' "bashunit_version": "%s",\n' "$(bashunit::reports::__json_escape "${BASHUNIT_VERSION:-unknown}")" printf ' "bash_version": "%s",\n' "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}.${BASH_VERSINFO[2]}" printf ' "os": "%s"\n' "$(bashunit::reports::__json_escape "$os")" printf ' },\n' printf ' "benchmarks": [\n' local total=${#_BASHUNIT_BENCH_NAMES[@]} local i sep name label file durations threshold verdict for i in $(bashunit::benchmark::_indexes); do name="${_BASHUNIT_BENCH_NAMES[$i]:-}" bashunit::helper::normalize_test_function_name_to_slot "$name" label=$_BASHUNIT_HELPER_NORMALIZED_OUT file="${_BASHUNIT_BENCH_FILES[$i]:-}" durations="${_BASHUNIT_BENCH_DURATIONS[$i]:-}" bashunit::benchmark::stats_to_slots "$durations" threshold="${_BASHUNIT_BENCH_MAX_MILLIS[$i]:-}" verdict=$(bashunit::benchmark::_verdict "$i") sep="," [ "$i" -eq "$((total - 1))" ] && sep="" printf ' {\n' printf ' "file": "%s",\n' "$(bashunit::reports::__json_escape "$file")" printf ' "function": "%s",\n' "$(bashunit::reports::__json_escape "$name")" printf ' "name": "%s",\n' "$(bashunit::reports::__json_escape "$label")" printf ' "revs": %s,\n' "${_BASHUNIT_BENCH_REVS[$i]:-0}" printf ' "its": %s,\n' "${_BASHUNIT_BENCH_ITS[$i]:-0}" printf ' "iterations_ms": [%s],\n' "$(bashunit::benchmark::_iterations_json "$durations")" printf ' "average_ms": %s,\n' "${_BASHUNIT_BENCH_AVERAGES[$i]:-0}" printf ' "min_ms": %s,\n' "${_BASHUNIT_BENCH_STATS_MIN_OUT:-0}" printf ' "max_ms": %s,\n' "${_BASHUNIT_BENCH_STATS_MAX_OUT:-0}" printf ' "median_ms": %s,\n' "${_BASHUNIT_BENCH_STATS_MEDIAN_OUT:-0}" printf ' "threshold_ms": %s,\n' "${threshold:-null}" printf ' "within_threshold": %s\n' "${verdict:-null}" printf ' }%s\n' "$sep" done printf ' ]\n' printf '}\n' } >"$output_file" } function bashunit::benchmark::report_junit() { local output_file=$1 local timestamp timestamp=$(date '+%Y-%m-%dT%H:%M:%S') local total=${#_BASHUNIT_BENCH_NAMES[@]} local failures=0 local i verdict for i in $(bashunit::benchmark::_indexes); do verdict=$(bashunit::benchmark::_verdict "$i") if [ "$verdict" = "false" ]; then failures=$((failures + 1)) fi done local run_seconds bashunit::reports::__ms_to_s "$(bashunit::benchmark::_run_duration_ms)" run_seconds=$_BASHUNIT_REPORTS_MS_TO_S_OUT { printf '\n' printf '\n' \ "$total" "$failures" "$run_seconds" printf ' \n' \ "$total" "$failures" "$run_seconds" "$timestamp" local name label file classname seconds threshold for i in $(bashunit::benchmark::_indexes); do name="${_BASHUNIT_BENCH_NAMES[$i]:-}" bashunit::helper::normalize_test_function_name_to_slot "$name" label=$(bashunit::reports::__xml_escape "$_BASHUNIT_HELPER_NORMALIZED_OUT") file="${_BASHUNIT_BENCH_FILES[$i]:-}" bashunit::reports::__junit_classname "$file" classname=$_BASHUNIT_REPORTS_CLASSNAME_OUT bashunit::reports::__ms_to_s "${_BASHUNIT_BENCH_AVERAGES[$i]:-0}" seconds=$_BASHUNIT_REPORTS_MS_TO_S_OUT threshold="${_BASHUNIT_BENCH_MAX_MILLIS[$i]:-}" printf ' \n' \ "$classname" "$label" "$(bashunit::reports::__xml_escape "$file")" "$seconds" if [ "$(bashunit::benchmark::_verdict "$i")" = "false" ]; then printf ' %s\n' \ "average ${_BASHUNIT_BENCH_AVERAGES[$i]:-0}ms exceeds @max_ms ${threshold}" \ "revs=${_BASHUNIT_BENCH_REVS[$i]:-0} its=${_BASHUNIT_BENCH_ITS[$i]:-0}" fi printf ' \n' done printf ' \n' printf '\n' } >"$output_file" } function bashunit::benchmark::_indexes() { local total=${#_BASHUNIT_BENCH_NAMES[@]} local i=0 while [ "$i" -lt "$total" ]; do printf '%s\n' "$i" i=$((i + 1)) done } function bashunit::benchmark::_run_duration_ms() { if [ -z "${_BASHUNIT_START_TIME:-}" ]; then printf '0' return fi local elapsed elapsed=$(bashunit::clock::total_runtime_in_milliseconds) case "$elapsed" in '' | *[!0-9.]*) elapsed=0 ;; esac printf '%s' "$elapsed" } # src/benchmark/baseline.sh _BASHUNIT_BASELINE_NAMES=() _BASHUNIT_BASELINE_MEDIANS=() function bashunit::benchmark::baseline_load() { local file=$1 _BASHUNIT_BASELINE_NAMES=() _BASHUNIT_BASELINE_MEDIANS=() if [ ! -f "$file" ] || [ ! -r "$file" ]; then printf "%sError: cannot read the baseline file: '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$file" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi local name median while IFS=$'\t' read -r name median; do [ -n "$name" ] || continue _BASHUNIT_BASELINE_NAMES[${#_BASHUNIT_BASELINE_NAMES[@]}]="$name" _BASHUNIT_BASELINE_MEDIANS[${#_BASHUNIT_BASELINE_MEDIANS[@]}]="$median" done < <(env LC_ALL=C awk ' # The document is bashunit s own, one field per line, so the pairing is a # matter of remembering the last "function" seen before each "median_ms". /"function"[[:space:]]*:/ { line = $0 sub(/.*"function"[[:space:]]*:[[:space:]]*"/, "", line) sub(/".*/, "", line) fn = line } /"median_ms"[[:space:]]*:/ { line = $0 sub(/.*"median_ms"[[:space:]]*:[[:space:]]*/, "", line) sub(/[^0-9.eE+-].*/, "", line) if (fn != "" && line != "") { printf "%s\t%s\n", fn, line; fn = "" } } ' "$file") if [ "${#_BASHUNIT_BASELINE_NAMES[@]}" -eq 0 ]; then printf "%sError: the baseline file holds no benchmark results: '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$file" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi } _BASHUNIT_BASELINE_MEDIAN_OUT="" function bashunit::benchmark::baseline_median_of() { local wanted=$1 local i=0 local total=${#_BASHUNIT_BASELINE_NAMES[@]} _BASHUNIT_BASELINE_MEDIAN_OUT="" while [ "$i" -lt "$total" ]; do if [ "${_BASHUNIT_BASELINE_NAMES[i]}" = "$wanted" ]; then _BASHUNIT_BASELINE_MEDIAN_OUT="${_BASHUNIT_BASELINE_MEDIANS[i]}" return 0 fi i=$((i + 1)) done return 1 } function bashunit::benchmark::baseline_delta() { local before=$1 local now=$2 local tolerance=$3 env LC_ALL=C awk -v before="$before" -v now="$now" -v tol="$tolerance" ' BEGIN { before = before + 0 now = now + 0 if (before <= 0) { # No usable previous number: report the change as unknown rather than # dividing by zero and calling it an infinite regression. printf "n/a\tno\n" exit } delta = ((now - before) / before) * 100 printf "%+.1f%%\t%s\n", delta, (delta > tol + 0 ? "yes" : "no") } ' } function bashunit::benchmark::baseline_compare() { local tolerance=$1 local regressed=0 printf "\nBaseline comparison (median ms, tolerance %s%%)\n" "$tolerance" bashunit::print_line 80 "=" printf '%-40s %12s %12s %10s\n' "Name" "Baseline" "Current" "Delta" local i total name median before delta verdict total=${#_BASHUNIT_BENCH_NAMES[@]} i=0 while [ "$i" -lt "$total" ]; do name="${_BASHUNIT_BENCH_NAMES[$i]:-}" bashunit::benchmark::stats_to_slots "${_BASHUNIT_BENCH_DURATIONS[$i]:-}" median=$_BASHUNIT_BENCH_STATS_MEDIAN_OUT if ! bashunit::benchmark::baseline_median_of "$name"; then printf '%-40s %12s %12s %10s\n' "$name" "-" "$median" "new" i=$((i + 1)) continue fi before=$_BASHUNIT_BASELINE_MEDIAN_OUT IFS=$'\t' read -r delta verdict < <( bashunit::benchmark::baseline_delta "$before" "$median" "$tolerance" ) if [ "$verdict" = "yes" ]; then regressed=$((regressed + 1)) printf '%-40s %12s %12s %s%10s%s\n' "$name" "$before" "$median" \ "$_BASHUNIT_COLOR_FAILED" "$delta" "$_BASHUNIT_COLOR_DEFAULT" else printf '%-40s %12s %12s %10s\n' "$name" "$before" "$median" "$delta" fi i=$((i + 1)) done local j baseline_total baseline_name found k baseline_total=${#_BASHUNIT_BASELINE_NAMES[@]} j=0 while [ "$j" -lt "$baseline_total" ]; do baseline_name="${_BASHUNIT_BASELINE_NAMES[$j]}" found=false k=0 while [ "$k" -lt "$total" ]; do if [ "${_BASHUNIT_BENCH_NAMES[$k]:-}" = "$baseline_name" ]; then found=true break fi k=$((k + 1)) done if [ "$found" = false ]; then printf '%-40s %12s %12s %10s\n' "$baseline_name" \ "${_BASHUNIT_BASELINE_MEDIANS[$j]}" "-" "removed" fi j=$((j + 1)) done if [ "$regressed" -gt 0 ]; then printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_FAILED" \ " Performance regression in $regressed benchmark(s) " "$_BASHUNIT_COLOR_DEFAULT" return 1 fi return 0 } # src/learn/index.sh # src/learn/progress.sh declare -r LEARN_PROGRESS_FILE="$HOME/.bashunit_learn_progress" function bashunit::learn::mark_completed() { local lesson=$1 echo "$lesson" >>"$LEARN_PROGRESS_FILE" } function bashunit::learn::is_completed() { local lesson=$1 [ -f "$LEARN_PROGRESS_FILE" ] && [ "$("$GREP" -c "^$lesson$" "$LEARN_PROGRESS_FILE" || true)" -gt 0 ] } 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 } 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 } # src/learn/session.sh LEARN_TEMP_DIR="" function bashunit::learn::init() { LEARN_TEMP_DIR=$("${MKTEMP:-mktemp}" -d "${TMPDIR:-/tmp}/bashunit_learn.XXXXXXXX") mkdir -p tests } function bashunit::learn::cleanup() { if [ -n "${LEARN_TEMP_DIR:-}" ] && [ -d "$LEARN_TEMP_DIR" ]; then rm -rf "$LEARN_TEMP_DIR" fi } 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 } function bashunit::learn::count_in_code() { local file=$1 local pattern=$2 "$GREP" -v '^[[:space:]]*#' "$file" | "$GREP" -c "$pattern" || true } 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 --fail-on-risky; 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 } # src/learn/lessons/basics.sh 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 if [ "$(bashunit::learn::count_in_code "$test_file" "assert_same")" -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 } # src/learn/lessons/assertions.sh 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 [ "$(bashunit::learn::count_in_code "$test_file" "assert_contains")" -eq 0 ] || [ "$(bashunit::learn::count_in_code "$test_file" "assert_matches")" -eq 0 ] || [ "$(bashunit::learn::count_in_code "$test_file" "assert_not_empty")" -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 } # src/learn/lessons/lifecycle.sh 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 [ "$(bashunit::learn::count_in_code "$test_file" "function set_up()")" -eq 0 ] || [ "$(bashunit::learn::count_in_code "$test_file" "function tear_down()")" -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 } # src/learn/lessons/functions.sh 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 [ "$(bashunit::learn::count_in_code "$test_file" "source")" -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 } # src/learn/lessons/scripts.sh 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 } # src/learn/lessons/mocking.sh 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 [ "$(bashunit::learn::count_in_code "$test_file" "mock")" -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 } # src/learn/lessons/spies.sh 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 [ "$(bashunit::learn::count_in_code "$test_file" "spy")" -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 } # src/learn/lessons/data_providers.sh 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 [ "$(bashunit::learn::count_in_code "$test_file" "function data_provider_")" -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 } # src/learn/lessons/exit_codes.sh 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 [ "$(bashunit::learn::count_in_code "$test_file" "$_exit_assert_pattern")" -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 } # src/learn/lessons/challenge.sh 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 local -a missing_components=() local missing_components_count=0 if [ "$(bashunit::learn::count_in_code "$test_file" "function set_up()")" -eq 0 ]; then missing_components[missing_components_count]="set_up function" missing_components_count=$((missing_components_count + 1)) fi if [ "$(bashunit::learn::count_in_code "$test_file" "function tear_down()")" -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 } # src/learn/menu.sh function bashunit::learn::print_menu() { cat <&2 exit 1 } function bashunit::main::require_non_negative_int_or_exit() { case "$1" in '' | *[!0-9]*) printf "%sError: %s must be a non-negative integer, got '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$2" "$1" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 ;; esac } function bashunit::main::report_path_is_a_directory() { printf "%sError: %s is a directory, not a file: '%s'.%s\n" \ "$_BASHUNIT_COLOR_FAILED" "${2:-path}" "$1" "$_BASHUNIT_COLOR_DEFAULT" >&2 exit 1 } function bashunit::main::report_unreadable_bootstrap() { local boot_file=$1 local raw=${2-} if [ ! -e "$boot_file" ]; then printf "%sError: the bootstrap file does not exist: '%s'.%s\n" \ "$_BASHUNIT_COLOR_FAILED" "$boot_file" "$_BASHUNIT_COLOR_DEFAULT" >&2 elif [ -d "$boot_file" ]; then printf "%sError: the bootstrap path is a directory, not a file: '%s'.%s\n" \ "$_BASHUNIT_COLOR_FAILED" "$boot_file" "$_BASHUNIT_COLOR_DEFAULT" >&2 elif [ ! -f "$boot_file" ]; then printf "%sError: the bootstrap path is not a regular file: '%s'.%s\n" \ "$_BASHUNIT_COLOR_FAILED" "$boot_file" "$_BASHUNIT_COLOR_DEFAULT" >&2 else printf "%sError: cannot read the bootstrap file: '%s'.%s\n" \ "$_BASHUNIT_COLOR_FAILED" "$boot_file" "$_BASHUNIT_COLOR_DEFAULT" >&2 fi if [ "$raw" != "$boot_file" ] && [ -r "$raw" ]; then printf "%s--env splits its value on the first space to pass bootstrap arguments,%s\n" \ "$_BASHUNIT_COLOR_FAINT" "$_BASHUNIT_COLOR_DEFAULT" >&2 printf "%sso a path containing one cannot be used. Set BASHUNIT_BOOTSTRAP='%s' instead.%s\n" \ "$_BASHUNIT_COLOR_FAINT" "$raw" "$_BASHUNIT_COLOR_DEFAULT" >&2 fi exit 1 } function bashunit::main::require_writable_path_or_exit() { local path=$1 local parent=${1%/*} [ "$parent" = "$1" ] && parent="." [ -z "$parent" ] && parent="/" if [ -d "$path" ]; then bashunit::main::report_path_is_a_directory "$path" "${2:-}" fi if [ -e "$path" ]; then [ -w "$path" ] && return 0 elif [ -d "$parent" ] && [ -w "$parent" ]; then return 0 fi printf "%sError: %s cannot be written: '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$2" "$path" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 } function bashunit::main::require_creatable_path_or_exit() { local path=$1 local ancestor=$1 while [ -n "$ancestor" ] && [ ! -e "$ancestor" ]; do case "$ancestor" in */*) ancestor=${ancestor%/*} ;; *) ancestor="." ;; esac done [ -z "$ancestor" ] && ancestor="/" if [ -d "$path" ]; then bashunit::main::report_path_is_a_directory "$path" "${2:-}" fi if [ -d "$ancestor" ] && [ -w "$ancestor" ] && { [ ! -e "$path" ] || [ -w "$path" ]; }; then return 0 fi printf "%sError: %s cannot be written: '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$2" "$path" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 } function bashunit::main::validate_config_or_exit() { if [ "${BASHUNIT_PARALLEL_JOBS:-0}" != "0" ]; then bashunit::main::require_non_negative_int_or_exit \ "${BASHUNIT_PARALLEL_JOBS}" "BASHUNIT_PARALLEL_JOBS (--jobs)" fi bashunit::main::require_non_negative_int_or_exit \ "${BASHUNIT_RETRY:-0}" "BASHUNIT_RETRY (--retry)" bashunit::main::require_non_negative_int_or_exit \ "${BASHUNIT_TEST_TIMEOUT:-0}" "BASHUNIT_TEST_TIMEOUT (--test-timeout)" bashunit::main::require_non_negative_int_or_exit \ "${BASHUNIT_REPEAT:-1}" "BASHUNIT_REPEAT (--repeat)" if [ "${BASHUNIT_REPEAT:-1}" -lt 1 ]; then printf "%sError: BASHUNIT_REPEAT (--repeat) must be at least 1, got '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${BASHUNIT_REPEAT}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi if [ -n "${BASHUNIT_COVERAGE_MIN:-}" ]; then bashunit::main::require_non_negative_int_or_exit \ "${BASHUNIT_COVERAGE_MIN}" "BASHUNIT_COVERAGE_MIN (--coverage-min)" fi bashunit::main::require_non_negative_int_or_exit \ "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" \ "BASHUNIT_COVERAGE_THRESHOLD_LOW" bashunit::main::require_non_negative_int_or_exit \ "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" \ "BASHUNIT_COVERAGE_THRESHOLD_HIGH" if [ -n "${BASHUNIT_SEED:-}" ]; then bashunit::main::require_non_negative_int_or_exit "${BASHUNIT_SEED}" "BASHUNIT_SEED (--seed)" fi if bashunit::env::is_shard_enabled; then bashunit::main::require_non_negative_int_or_exit \ "${BASHUNIT_SHARD_INDEX}" "BASHUNIT_SHARD_INDEX" bashunit::main::require_non_negative_int_or_exit \ "${BASHUNIT_SHARD_TOTAL}" "BASHUNIT_SHARD_TOTAL" if [ "$BASHUNIT_SHARD_TOTAL" -lt 1 ] || [ "$BASHUNIT_SHARD_INDEX" -lt 1 ] || [ "$BASHUNIT_SHARD_INDEX" -gt "$BASHUNIT_SHARD_TOTAL" ]; then printf "%sError: BASHUNIT_SHARD_INDEX/BASHUNIT_SHARD_TOTAL must satisfy 1 <= index <= total.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi fi if bashunit::env::is_changed_enabled; then if ! bashunit::helper::git_is_repo; then printf "%sError: --changed needs a git work tree; '%s' is not inside one.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$PWD" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi local _changed_ref _changed_ref="$(bashunit::helper::git_changed_ref)" if ! bashunit::helper::git_ref_exists "$_changed_ref"; then printf "%sError: --changed cannot resolve the git ref '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$_changed_ref" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi fi local _report_var _report_path for _report_var in BASHUNIT_LOG_JUNIT BASHUNIT_LOG_GHA BASHUNIT_REPORT_HTML \ BASHUNIT_REPORT_TAP BASHUNIT_REPORT_JSON BASHUNIT_REPORT_MD; do _report_path=${!_report_var:-} if [ -n "$_report_path" ]; then bashunit::main::require_writable_path_or_exit "$_report_path" "$_report_var" fi done if [ -n "${BASHUNIT_COVERAGE_REPORT_COBERTURA:-}" ]; then bashunit::main::require_creatable_path_or_exit \ "$BASHUNIT_COVERAGE_REPORT_COBERTURA" "BASHUNIT_COVERAGE_REPORT_COBERTURA" fi case "${BASHUNIT_OUTPUT_FORMAT:-}" in '' | text | tap | json | junit) ;; *) printf "%sError: unsupported output format '%s' for --output. Supported: text, tap, json, junit.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${BASHUNIT_OUTPUT_FORMAT}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 ;; esac case "${BASHUNIT_SANDBOX_ALLOW:-}" in '') ;; *[!A-Za-z0-9_.+,-]*) printf "%sError: invalid --sandbox-allow value '%s'. Expected a comma-separated list of commands.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${BASHUNIT_SANDBOX_ALLOW}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 ;; esac case "${BASHUNIT_GHA_ANNOTATIONS:-auto}" in auto | always | never) ;; *) printf "%sError: unsupported mode '%s' for --gha-annotations. Supported: auto, always, never.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${BASHUNIT_GHA_ANNOTATIONS}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 ;; esac case "${BASHUNIT_ORDER_BY:-defined}" in defined | defects | random) ;; *) printf "%sError: unsupported order '%s' for --order-by. Supported: defined, defects, random.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${BASHUNIT_ORDER_BY}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 ;; esac if [ -n "${BASHUNIT_COVERAGE_DIFF:-}" ]; then if ! bashunit::dependencies::has_git; then printf "%sError: --coverage-diff needs git, which was not found.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi if ! bashunit::helper::git_is_repo; then printf "%sError: --coverage-diff needs a git repository; '%s' is not inside one.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$(pwd)" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi if ! bashunit::helper::git_ref_exists "${BASHUNIT_COVERAGE_DIFF}"; then printf "%sError: --coverage-diff base '%s' does not resolve to a commit. \ On a shallow clone, fetch it first (git fetch --depth=... origin %s).%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${BASHUNIT_COVERAGE_DIFF}" \ "${BASHUNIT_COVERAGE_DIFF}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi fi case "${BASHUNIT_LIST_FORMAT:-}" in text | json) ;; *) printf "%sError: unsupported list format '%s' for --list-format. Supported: text, json.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "${BASHUNIT_LIST_FORMAT}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 ;; esac } function bashunit::main::require_valid_tag_expression_or_exit() { local value="${1:-}" local IFS=',' local expression for expression in $value; do local rest="$expression" local term more=true while [ "$more" = true ]; do case "$rest" in *"&&"*) term="${rest%%&&*}" rest="${rest#*&&}" ;; *) term="$rest" rest="" more=false ;; esac term="${term#"${term%%[![:space:]]*}"}" term="${term%"${term##*[![:space:]]}"}" case "$term" in '!'*) term="${term#!}" term="${term#"${term%%[![:space:]]*}"}" ;; esac if [ -z "$term" ]; then printf "%sError: invalid tag expression '%s' for --tag. \ Use 'a', 'a&&b', '!a' or 'a&&!b'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$expression" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi done done } 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 } # src/main/run.sh function bashunit::main::exec_tests() { local filter=$1 local tag_filter="${2:-}" local exclude_tag_filter="${3:-}" shift 3 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 if bashunit::env::is_changed_enabled; then local _changed_ref _changed_ref=$(bashunit::helper::git_changed_ref) local -a _changed_files=() local _changed_file while IFS= read -r _changed_file; do [ -z "$_changed_file" ] && continue _changed_files[${#_changed_files[@]}]="$_changed_file" done < <(bashunit::helper::git_filter_changed "$_changed_ref" "${test_files[@]}") test_files=("${_changed_files[@]+"${_changed_files[@]}"}") test_files_count=${#test_files[@]} bashunit::internal_log "changed" "ref:$_changed_ref" "files:$test_files_count" fi 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 'bashunit::main::cleanup' SIGINT trap '[ $? -eq $EXIT_CODE_STOP_ON_FAILURE ] && bashunit::main::handle_stop_on_failure_sync' EXIT 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, Alpine and Windows.\n" \ "${_BASHUNIT_COLOR_INCOMPLETE}" printf "On other systems --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 bashunit::sandbox::prepare if bashunit::env::is_list_enabled; then : elif bashunit::env::is_tap_output_enabled; then printf "TAP version 13\n" elif bashunit::env::is_machine_output_enabled; then : else bashunit::console_header::print_version_with_env "$filter" "${test_files[@]}" fi 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_machine_output_enabled && ! bashunit::env::is_list_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::env::is_list_enabled; then bashunit::runner::list_render_summary bashunit::env::cleanup_run_output_dir exit 0 fi if bashunit::parallel::is_enabled; then wait fi if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure && ! bashunit::env::is_machine_output_enabled; then printf "%sStop on failure enabled...%s\n" "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" fi if ! bashunit::env::is_machine_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=$? bashunit::reports::load_spooled if bashunit::env::is_coverage_enabled; then bashunit::coverage::finalize if bashunit::parallel::is_enabled; then bashunit::coverage::aggregate_parallel fi bashunit::coverage::precompute_file_stats fi if [ -n "$BASHUNIT_REPORT_MD" ]; then bashunit::reports::generate_report_md "$BASHUNIT_REPORT_MD" elif bashunit::env::should_append_step_summary; then bashunit::reports::append_step_summary fi if bashunit::env::is_profile_enabled && ! bashunit::env::is_machine_output_enabled; then bashunit::console_results::print_profile_and_reset fi if bashunit::env::is_snapshot_report_unused_enabled; then bashunit::snapshot::report_unused ${test_files[@]+"${test_files[@]}"} fi if bashunit::env::is_snapshot_prune_enabled; then bashunit::snapshot::prune_unused ${test_files[@]+"${test_files[@]}"} fi if bashunit::env::should_print_gha_annotations; then bashunit::reports::print_gha_annotations all 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 if bashunit::env::is_json_output_enabled; then bashunit::reports::print_report_json elif bashunit::env::is_junit_output_enabled; then bashunit::reports::print_junit_xml fi if bashunit::env::is_coverage_enabled; then if bashunit::coverage::is_diff_enabled; then if bashunit::env::is_machine_output_enabled; then bashunit::coverage::report_diff >/dev/null else bashunit::coverage::report_diff fi elif ! bashunit::env::is_machine_output_enabled; then bashunit::coverage::report_text fi if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then bashunit::coverage::report_lcov "$BASHUNIT_COVERAGE_REPORT" fi if [ -n "$BASHUNIT_COVERAGE_REPORT_COBERTURA" ]; then bashunit::coverage::report_cobertura "$BASHUNIT_COVERAGE_REPORT_COBERTURA" fi if [ -n "$BASHUNIT_COVERAGE_REPORT_HTML" ]; then bashunit::coverage::report_html "$BASHUNIT_COVERAGE_REPORT_HTML" fi if ! bashunit::coverage::check_threshold; then exit_code=1 fi bashunit::coverage::cleanup fi if bashunit::parallel::is_enabled; then bashunit::parallel::cleanup fi bashunit::rerun::persist 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 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[@]}" if [ "${#_BASHUNIT_BENCH_NAMES[@]}" -eq 0 ]; then printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_ERROR" " No benchmarks found " \ "$_BASHUNIT_COLOR_DEFAULT" exit 1 fi bashunit::benchmark::print_results if [ -n "${BASHUNIT_BENCH_REPORT_JSON:-}" ]; then bashunit::benchmark::report_json "$BASHUNIT_BENCH_REPORT_JSON" fi if [ -n "${BASHUNIT_BENCH_REPORT_JUNIT:-}" ]; then bashunit::benchmark::report_junit "$BASHUNIT_BENCH_REPORT_JUNIT" fi if [ -n "${BASHUNIT_BENCH_BASELINE_UPDATE:-}" ]; then bashunit::benchmark::report_json "$BASHUNIT_BENCH_BASELINE_UPDATE" fi if [ -n "${BASHUNIT_BENCH_BASELINE:-}" ]; then bashunit::benchmark::baseline_load "$BASHUNIT_BENCH_BASELINE" if ! bashunit::benchmark::baseline_compare "${BASHUNIT_BENCH_BASELINE_TOLERANCE:-10}"; then exit 1 fi fi 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}" 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 } # src/main/watch.sh 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 } # src/main/assert.sh function bashunit::main::is_assertion_function() { local name="$1" declare -F "assert_$name" &>/dev/null || declare -F "$name" &>/dev/null } 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 set +euo pipefail if bashunit::main::is_assertion_function "$first_arg"; then local assert_fn="$first_arg" shift bashunit::main::exec_assert "$assert_fn" "$@" elif [ $# -ge 2 ] && bashunit::main::is_assertion_function "$2"; then bashunit::main::exec_multi_assert "$@" else bashunit::main::exec_assert "$@" fi exit $? } 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 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 if [ "$args_count" -lt 1 ]; then printf "%sError: assert %s requires at least one argument.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$original_assert_fn" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi local last_index=$((args_count - 1)) local last_arg="${args[$last_index]}" local output="" local inner_exit_code=0 local bashunit_exit_code=0 case "$assert_fn" in assert_exit_code) output=$(bashunit::main::handle_assert_exit_code "$last_arg") inner_exit_code=$? args=("${args[@]:0:last_index}") args[last_index]="$inner_exit_code" ;; *) # Every other assertion takes its argument as-is; no rewriting needed. ;; esac if [ -n "$output" ]; then echo "$output" 1>&1 assert_fn="assert_same" fi bashunit::state::set_test_title "assert ${original_assert_fn#assert_}" "$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 } function bashunit::main::exec_multi_assert() { local cmd="$1" shift 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 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 local stdout local cmd_exit_code stdout=$(eval "$cmd" 2>&1) cmd_exit_code=$? if [ -n "$stdout" ]; then echo "$stdout" 1>&1 fi 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 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 bashunit::state::set_test_title "assert ${assertion_name#assert_}" if bashunit::main::is_exit_code_assertion "$assertion_name"; then "$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2 else "$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 } # src/main/subcommands.sh function bashunit::main::cmd_doc() { local filter="" local custom_only=false local boot_file="${BASHUNIT_BOOTSTRAP:-}" local boot_provided=false while [ $# -gt 0 ]; do case "$1" in -h | --help) bashunit::console_header::print_doc_help exit 0 ;; --custom) custom_only=true shift ;; -e | --env | --boot) boot_file="${2:-}" boot_provided=true shift 2 ;; *) filter="$1" shift ;; esac done local known known="$(compgen -A function assert_ 2>/dev/null)" if [ -n "$boot_file" ]; then if [ ! -r "$boot_file" ]; then if [ "$boot_provided" = true ]; then printf "%sError: cannot read the bootstrap file: '%s'.%s\n" \ "$_BASHUNIT_COLOR_FAILED" "$boot_file" "$_BASHUNIT_COLOR_DEFAULT" >&2 exit 1 fi else _BASHUNIT_LOADING_BOOTSTRAP="$boot_file" source "$boot_file" ${BASHUNIT_BOOTSTRAP_ARGS:-} _BASHUNIT_LOADING_BOOTSTRAP="" fi fi bashunit::doc::custom_fns_to_slot "$known" if [ "$custom_only" = true ]; then if ! bashunit::doc::print_custom_asserts "$filter"; then printf 'No custom assertions found.\n' printf 'Load them with --boot or BASHUNIT_BOOTSTRAP.\n' fi exit 0 fi local rendered rendered=$(bashunit::doc::print_asserts "$filter") if [ -n "$rendered" ]; then printf '%s\n' "$rendered" elif [ -z "$_BASHUNIT_DOC_CUSTOM_FNS_OUT" ]; then printf "No assertion matches '%s'.\n" "$filter" printf 'Run `bashunit doc` without a filter to list them all.\n' fi if [ -n "$_BASHUNIT_DOC_CUSTOM_FNS_OUT" ]; then printf '\n## Custom assertions\n\n' bashunit::doc::print_custom_asserts "$filter" || true fi exit 0 } 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 } function bashunit::main::cmd_learn() { case "${1:-}" in -h | --help) bashunit::console_header::print_learn_help exit 0 ;; esac bashunit::learn::start exit 0 } 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) 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[@]}"}" } function bashunit::main::cmd_upgrade() { case "${1:-}" in -h | --help) bashunit::console_header::print_upgrade_help exit 0 ;; esac bashunit::upgrade::upgrade exit 0 } # src/main/bench.sh 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 while [ $# -gt 0 ]; do case "$1" in -f | --filter) filter="$2" shift ;; --baseline) BASHUNIT_BENCH_BASELINE="$2" export -n BASHUNIT_BENCH_BASELINE shift ;; --baseline-tolerance) BASHUNIT_BENCH_BASELINE_TOLERANCE="$2" export -n BASHUNIT_BENCH_BASELINE_TOLERANCE case "$BASHUNIT_BENCH_BASELINE_TOLERANCE" in '' | *[!0-9.]*) printf "%sError: --baseline-tolerance expects a percentage, got '%s'.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$2" "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 ;; esac shift ;; --baseline-update) BASHUNIT_BENCH_BASELINE_UPDATE="$2" export -n BASHUNIT_BENCH_BASELINE_UPDATE bashunit::main::require_writable_path_or_exit \ "$BASHUNIT_BENCH_BASELINE_UPDATE" "BASHUNIT_BENCH_BASELINE_UPDATE" shift ;; --report-json) BASHUNIT_BENCH_REPORT_JSON="$2" export -n BASHUNIT_BENCH_REPORT_JSON bashunit::main::require_writable_path_or_exit \ "$BASHUNIT_BENCH_REPORT_JSON" "BASHUNIT_BENCH_REPORT_JSON" shift ;; --report-junit) BASHUNIT_BENCH_REPORT_JUNIT="$2" export -n BASHUNIT_BENCH_REPORT_JUNIT bashunit::main::require_writable_path_or_exit \ "$BASHUNIT_BENCH_REPORT_JUNIT" "BASHUNIT_BENCH_REPORT_JUNIT" 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) local boot_file="${2%% *}" local boot_args="${2#* }" if [ "$boot_args" != "$2" ]; then BASHUNIT_BOOTSTRAP_ARGS="$boot_args" export -n BASHUNIT_BOOTSTRAP_ARGS fi if [ ! -f "$boot_file" ] || [ ! -r "$boot_file" ]; then bashunit::main::report_unreadable_bootstrap "$boot_file" "$2" fi set -o allexport _BASHUNIT_LOADING_BOOTSTRAP="$boot_file" source "$boot_file" ${BASHUNIT_BOOTSTRAP_ARGS:-} _BASHUNIT_LOADING_BOOTSTRAP="" 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) BASHUNIT_NO_COLOR=true ;; -h | --help) bashunit::console_header::print_bench_help exit 0 ;; -*) bashunit::main::abort_unknown_option "$1" "bench" ;; *) raw_args[raw_args_count]="$1" raw_args_count=$((raw_args_count + 1)) ;; esac shift done 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 if [ -f "${BASHUNIT_BOOTSTRAP:-}" ]; then _BASHUNIT_LOADING_BOOTSTRAP="$BASHUNIT_BOOTSTRAP" source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} _BASHUNIT_LOADING_BOOTSTRAP="" fi set +euo pipefail if [ "$args_count" -gt 0 ]; then bashunit::main::exec_benchmarks "$filter" "${args[@]}" else bashunit::main::exec_benchmarks "$filter" fi } # src/main/test.sh _BASHUNIT_MAIN_SUITE_ARGV=() function bashunit::main::apply_suites() { local -a suite_names=() local -a rest=() local -a cli_paths=() local has_suite=false while [ $# -gt 0 ]; do case "$1" in --suite) if [ -z "${2:-}" ]; then printf "%sError: --suite requires a name.%s\n" \ "${_BASHUNIT_COLOR_FAILED:-}" "${_BASHUNIT_COLOR_DEFAULT:-}" >&2 exit 1 fi has_suite=true suite_names[${#suite_names[@]}]="$2" shift ;; --list-suites) bashunit::suites::load ".bashunitrc" bashunit::suites::names exit 0 ;; -*) rest[${#rest[@]}]="$1" ;; *) cli_paths[${#cli_paths[@]}]="$1" rest[${#rest[@]}]="$1" ;; esac shift done if [ "$has_suite" = false ]; then _BASHUNIT_MAIN_SUITE_ARGV=(${rest[@]+"${rest[@]}"}) return 0 fi bashunit::suites::load ".bashunitrc" local -a expanded=() local -a suite_paths=() local name entry path for name in ${suite_names[@]+"${suite_names[@]}"}; do bashunit::suites::resolve "$name" while IFS= read -r entry; do [ -z "$entry" ] && continue expanded[${#expanded[@]}]="$entry" done <"$output_file" 2>&1 shift fi set -x ;; -S | --stop-on-failure) 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 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 ;; --repeat) BASHUNIT_REPEAT="$2" export -n BASHUNIT_REPEAT shift ;; --random-order) BASHUNIT_RANDOM_ORDER=true export -n BASHUNIT_RANDOM_ORDER ;; --order-by) BASHUNIT_ORDER_BY="$2" export -n BASHUNIT_ORDER_BY shift ;; --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 ;; --changed) BASHUNIT_CHANGED=true export -n BASHUNIT_CHANGED if [ -n "${2:-}" ] && [ "${2#-}" = "${2:-}" ] && [ ! -e "$2" ]; then BASHUNIT_CHANGED_REF="$2" export -n BASHUNIT_CHANGED_REF shift fi ;; --list | --dry-run) BASHUNIT_LIST_TESTS=true export -n BASHUNIT_LIST_TESTS ;; --list-format) BASHUNIT_LIST_FORMAT="$2" export -n BASHUNIT_LIST_FORMAT shift ;; --snapshot-update) BASHUNIT_SNAPSHOT_UPDATE=true export -n BASHUNIT_SNAPSHOT_UPDATE ;; --no-snapshot-create) BASHUNIT_SNAPSHOT_CREATE=false export -n BASHUNIT_SNAPSHOT_CREATE ;; --snapshot-prune) BASHUNIT_SNAPSHOT_PRUNE=true export -n BASHUNIT_SNAPSHOT_PRUNE ;; --snapshot-report-unused) BASHUNIT_SNAPSHOT_REPORT_UNUSED=true export -n BASHUNIT_SNAPSHOT_REPORT_UNUSED ;; -w | --watch) BASHUNIT_WATCH_MODE=true export -n BASHUNIT_WATCH_MODE ;; -e | --env | --boot) local boot_file="${2%% *}" local boot_args="${2#* }" if [ "$boot_args" != "$2" ]; then BASHUNIT_BOOTSTRAP_ARGS="$boot_args" export -n BASHUNIT_BOOTSTRAP_ARGS fi if [ ! -f "$boot_file" ] || [ ! -r "$boot_file" ]; then bashunit::main::report_unreadable_bootstrap "$boot_file" "$2" fi set -o allexport _BASHUNIT_LOADING_BOOTSTRAP="$boot_file" source "$boot_file" ${BASHUNIT_BOOTSTRAP_ARGS:-} _BASHUNIT_LOADING_BOOTSTRAP="" set +o allexport shift ;; --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 ;; --gha-annotations) BASHUNIT_GHA_ANNOTATIONS="$2" export -n BASHUNIT_GHA_ANNOTATIONS 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-md) BASHUNIT_REPORT_MD="$2" export -n BASHUNIT_REPORT_MD 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 ;; --fail-on-flaky) BASHUNIT_FAIL_ON_FLAKY=true export -n BASHUNIT_FAIL_ON_FLAKY ;; --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) BASHUNIT_NO_COLOR=true ;; --coverage) BASHUNIT_COVERAGE=true ;; --coverage-paths) BASHUNIT_COVERAGE_PATHS="$2" shift ;; --coverage-exclude) BASHUNIT_COVERAGE_EXCLUDE="$2" shift ;; --coverage-report) case "${2:-}" in '' | -*) BASHUNIT_COVERAGE_REPORT="$_BASHUNIT_DEFAULT_COVERAGE_REPORT" ;; *) BASHUNIT_COVERAGE_REPORT="$2" shift ;; esac _bashunit_coverage_opt_set=true ;; --coverage-min) BASHUNIT_COVERAGE_MIN="$2" _bashunit_coverage_opt_set=true shift ;; --coverage-diff) BASHUNIT_COVERAGE_DIFF="$2" _bashunit_coverage_opt_set=true shift ;; --no-coverage-report) BASHUNIT_COVERAGE_REPORT="" ;; --coverage-report-html) 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 ;; --coverage-report-cobertura) if [ -z "${2:-}" ]; then BASHUNIT_COVERAGE_REPORT_COBERTURA="coverage/cobertura.xml" else case "${2:-}" in -*) BASHUNIT_COVERAGE_REPORT_COBERTURA="coverage/cobertura.xml" ;; *) BASHUNIT_COVERAGE_REPORT_COBERTURA="$2" shift ;; esac fi _bashunit_coverage_opt_set=true ;; -*) bashunit::main::abort_unknown_option "$1" "test" ;; *) raw_args[raw_args_count]="$1" raw_args_count=$((raw_args_count + 1)) ;; esac shift done bashunit::main::validate_config_or_exit if [ "$_bashunit_coverage_opt_set" = true ]; then BASHUNIT_COVERAGE=true fi local inline_filter="" local inline_filter_file="" if [ "$raw_args_count" -gt 0 ]; then if [ -n "$assert_fn" ]; then args=("${raw_args[@]}") args_count="$raw_args_count" else 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 [ -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 case "$inline_filter" in "__line__:"*) local line_number="${inline_filter#__line__:}" local resolved_file="${inline_filter_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 if [ -z "$filter" ] && [ -n "$inline_filter" ]; then filter="$inline_filter" fi fi fi if bashunit::env::is_snapshot_report_unused_enabled || bashunit::env::is_snapshot_prune_enabled; then local _snapshot_flag="--snapshot-report-unused" if bashunit::env::is_snapshot_prune_enabled; then _snapshot_flag="--snapshot-prune" fi local _partial_flag="" [ -n "$filter" ] && _partial_flag="--filter" [ -n "$tag_filter" ] && _partial_flag="--tag" [ -n "$exclude_tag_filter" ] && _partial_flag="--exclude-tag" [ -n "${BASHUNIT_SHARD_INDEX:-}" ] && _partial_flag="--shard" bashunit::rerun::is_enabled && _partial_flag="--rerun-failed" bashunit::env::is_changed_enabled && _partial_flag="--changed" if [ -n "$_partial_flag" ]; then printf "%sError: %s needs a full run; %s only runs a subset.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$_snapshot_flag" "$_partial_flag" \ "${_BASHUNIT_COLOR_DEFAULT}" >&2 exit 1 fi fi 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 [ -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 if [ -f "${BASHUNIT_BOOTSTRAP:-}" ]; then _BASHUNIT_LOADING_BOOTSTRAP="$BASHUNIT_BOOTSTRAP" source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} _BASHUNIT_LOADING_BOOTSTRAP="" fi if [ "${BASHUNIT_NO_OUTPUT:-false}" = true ]; then exec >/dev/null 2>&1 fi set +euo pipefail if [ -n "$assert_fn" ]; then 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 } #!/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 current_version="${BASHUNIT_TEST_BASH_VERSION}" elif [[ -n ${BASH_VERSINFO+set} ]]; then current_version="${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" else current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)" fi local major minor min_major min_minor IFS=. read -r major minor <<<"$current_version" IFS=. read -r min_major min_minor <<<"$BASHUNIT_MIN_BASH_VERSION" major=${major%%[!0-9]*} minor=${minor%%[!0-9]*} if ((10#${major:-0} < 10#$min_major)) || { ((10#${major:-0} == 10#$min_major)) && ((10#${minor:-0} < 10#${min_minor:-0})); }; then printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 exit 1 fi } _check_bash_version declare -r BASHUNIT_VERSION="0.48.0" _bashunit_root="${BASH_SOURCE[0]%/*}" case "$_bashunit_root" in "${BASH_SOURCE[0]}") _bashunit_root="." ;; "") _bashunit_root="/" ;; esac declare -r BASHUNIT_ROOT_DIR="$_bashunit_root" unset _bashunit_root export BASHUNIT_ROOT_DIR declare -r BASHUNIT_WORKING_DIR="$PWD" export BASHUNIT_WORKING_DIR for arg in "$@"; do case "$arg" in --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) BASHUNIT_NO_COLOR=true ;; esac done bashunit::clock::init _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 ;; -*) _SUBCOMMAND="test" ;; "") _SUBCOMMAND="test" ;; *) _SUBCOMMAND="test" ;; esac 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