From 087e31d38f584b47b9df1d6c3e410fdf0f8e4fbf Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 27 Sep 2025 13:43:53 +0200 Subject: [PATCH 01/11] feat: lower bash support from 3.2 to 3.0 --- .github/CONTRIBUTING.md | 2 +- .github/copilot-instructions.md | 6 +++--- CHANGELOG.md | 1 + bashunit | 4 ++-- build.sh | 3 ++- install.sh | 3 ++- src/assert.sh | 6 ++++-- src/benchmark.sh | 18 ++++++++++++------ src/clock.sh | 3 ++- src/helpers.sh | 6 ++++-- src/main.sh | 3 ++- src/runner.sh | 3 ++- src/test_doubles.sh | 3 ++- tests/unit/bash_version_test.sh | 10 ++++++++-- 14 files changed, 47 insertions(+), 24 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 86835dbb..23f2c194 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -31,7 +31,7 @@ Contributions are licensed under the [MIT License](https://github.com/TypedDevs/ ### Prerequisites -- Bash 3.2+ +- Bash 3.0+ - Git - Make - [ShellCheck](https://github.com/koalaman/shellcheck#installing) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7bc31bbd..e59023ec 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,7 +28,7 @@ An open-source **library** providing a fast, portable Bash testing framework: ** * Minimal overhead, plain Bash test files. * Rich **assertions**, **test doubles (mock/spy)**, **data providers**, **snapshots**, **skip/todo**, **globals utilities**, **custom assertions**, **benchmarks**, and **standalone** runs. -**Compatibility**: Bash 3.2+ (macOS, Linux, WSL). No external dependencies beyond standard Unix tools. +**Compatibility**: Bash 3.0+ (macOS, Linux, WSL). No external dependencies beyond standard Unix tools. --- @@ -284,7 +284,7 @@ We practice two nested feedback loops to deliver behavior safely and quickly. ### Compatibility & Portability ```bash -# ✅ GOOD - Works on Bash 3.2+ +# ✅ GOOD - Works on Bash 3.0+ [[ -n "${var:-}" ]] && echo "set" array=("item1" "item2") @@ -1000,7 +1000,7 @@ Use this template for internal changes, fixes, refactors, documentation. - **All tests pass** (`./bashunit tests/`) - **Shellcheck passes** with existing exceptions (`shellcheck -x $(find . -name "*.sh")`) - **Code formatted** (`shfmt -w .`) -- **Bash 3.2+ compatible** (no `declare -A`, no `readarray`, no `${var^^}`) +- **Bash 3.0+ compatible** (no `declare -A`, no `readarray`, no `${var^^}`) - **Follows established module namespacing** patterns ### ✅ Testing (following observed patterns) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbde231..9ff90e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - tear_down - set_up_before_script - tear_down_after_script +- Support Bash 3.0 (Previously 3.2) ## [0.24.0](https://github.com/TypedDevs/bashunit/compare/0.23.0...0.24.0) - 2025-09-14 diff --git a/bashunit b/bashunit index 953965af..baf1d213 100755 --- a/bashunit +++ b/bashunit @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -declare -r BASHUNIT_MIN_BASH_VERSION="3.2" +declare -r BASHUNIT_MIN_BASH_VERSION="3.0" function _check_bash_version() { local current_version @@ -19,7 +19,7 @@ function _check_bash_version() { local major minor IFS=. read -r major minor _ <<< "$current_version" - if (( major < 3 )) || { (( major == 3 )) && (( minor < 2 )); }; then + if (( major < 3 )); then printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 exit 1 fi diff --git a/build.sh b/build.sh index 081a87b7..5ddc84de 100755 --- a/build.sh +++ b/build.sh @@ -71,7 +71,8 @@ function build::process_file() { sourced_file=$(eval echo "$sourced_file") # Handle relative paths if necessary - if [[ ! "$sourced_file" =~ ^/ ]]; then + local absolute_path_pattern='^/' + if [[ ! "$sourced_file" =~ $absolute_path_pattern ]]; then sourced_file="$(dirname "$file")/$sourced_file" fi diff --git a/install.sh b/install.sh index 981b54a0..c9497ba5 100755 --- a/install.sh +++ b/install.sh @@ -68,7 +68,8 @@ DIR="lib" VERSION="latest" function is_version() { - [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "$1" == "latest" || "$1" == "beta" ]] + local version_pattern='^[0-9]+\.[0-9]+\.[0-9]+$' + [[ "$1" =~ $version_pattern || "$1" == "latest" || "$1" == "beta" ]] } # Parse arguments flexibly diff --git a/src/assert.sh b/src/assert.sh index bd9d061b..ec63971d 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -51,10 +51,12 @@ function assert_false() { function run_command_or_eval() { local cmd="$1" + local eval_pattern='^eval' + local alias_pattern='^alias' - if [[ "$cmd" =~ ^eval ]]; then + if [[ "$cmd" =~ $eval_pattern ]]; then eval "${cmd#eval }" &> /dev/null - elif [[ "$(command -v "$cmd")" =~ ^alias ]]; then + elif [[ "$(command -v "$cmd")" =~ $alias_pattern ]]; then eval "$cmd" &> /dev/null else "$cmd" &> /dev/null diff --git a/src/benchmark.sh b/src/benchmark.sh index 32b9eb91..7776855e 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -16,21 +16,27 @@ function benchmark::parse_annotations() { local annotation annotation=$(awk "/function[[:space:]]+${fn_name}[[:space:]]*\(/ {print prev; exit} {prev=\$0}" "$script") - if [[ $annotation =~ @revs=([0-9]+) ]]; then + local revs_pattern='@revs=([0-9]+)' + local revolutions_pattern='@revolutions=([0-9]+)' + local its_pattern='@its=([0-9]+)' + local iterations_pattern='@iterations=([0-9]+)' + local max_ms_pattern='@max_ms=([0-9.]+)' + + if [[ $annotation =~ $revs_pattern ]]; then revs="${BASH_REMATCH[1]}" - elif [[ $annotation =~ @revolutions=([0-9]+) ]]; then + elif [[ $annotation =~ $revolutions_pattern ]]; then revs="${BASH_REMATCH[1]}" fi - if [[ $annotation =~ @its=([0-9]+) ]]; then + if [[ $annotation =~ $its_pattern ]]; then its="${BASH_REMATCH[1]}" - elif [[ $annotation =~ @iterations=([0-9]+) ]]; then + elif [[ $annotation =~ $iterations_pattern ]]; then its="${BASH_REMATCH[1]}" fi - if [[ $annotation =~ @max_ms=([0-9.]+) ]]; then + if [[ $annotation =~ $max_ms_pattern ]]; then max_ms="${BASH_REMATCH[1]}" - elif [[ $annotation =~ @max_ms=([0-9.]+) ]]; then + elif [[ $annotation =~ $max_ms_pattern ]]; then max_ms="${BASH_REMATCH[1]}" fi diff --git a/src/clock.sh b/src/clock.sh index 27aed6cf..e35f98d0 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -37,8 +37,9 @@ function clock::_choose_impl() { attempts+=("date") if ! check_os::is_macos && ! check_os::is_alpine; then local result + local number_pattern='^[0-9]+$' result=$(date +%s%N 2>/dev/null) - if [[ "$result" != *N && "$result" =~ ^[0-9]+$ ]]; then + if [[ "$result" != *N && "$result" =~ $number_pattern ]]; then _CLOCK_NOW_IMPL="date" return 0 fi diff --git a/src/helpers.sh b/src/helpers.sh index 6ffe1e29..1baadbde 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -160,7 +160,8 @@ function helper::find_files_recursive() { local pattern="${2:-*[tT]est.sh}" local alt_pattern="" - if [[ $pattern == *test.sh ]] || [[ $pattern =~ \[tT\]est\.sh$ ]]; then + local test_pattern='\[tT\]est\.sh$' + if [[ $pattern == *test.sh ]] || [[ $pattern =~ $test_pattern ]]; then alt_pattern="${pattern%.sh}.bash" fi @@ -187,7 +188,8 @@ function helper::normalize_variable_name() { normalized_string="${input_string//[^a-zA-Z0-9_]/_}" - if [[ ! $normalized_string =~ ^[a-zA-Z_] ]]; then + local valid_start_pattern='^[a-zA-Z_]' + if [[ ! $normalized_string =~ $valid_start_pattern ]]; then normalized_string="_$normalized_string" fi diff --git a/src/main.sh b/src/main.sh index 0f220ad7..6b808794 100644 --- a/src/main.sh +++ b/src/main.sh @@ -189,7 +189,8 @@ function main::handle_assert_exit_code() { last_line=$(echo "$output" | tail -n 1) if echo "$last_line" | grep -q 'inner_exit_code:[0-9]*'; then inner_exit_code=$(echo "$last_line" | grep -o 'inner_exit_code:[0-9]*' | cut -d':' -f2) - if ! [[ $inner_exit_code =~ ^[0-9]+$ ]]; then + local number_pattern='^[0-9]+$' + if ! [[ $inner_exit_code =~ $number_pattern ]]; then inner_exit_code=1 fi output=$(echo "$output" | sed '$d') diff --git a/src/runner.sh b/src/runner.sh index 3544e382..9483edfe 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -113,7 +113,8 @@ function runner::parse_data_provider_args() { local i local arg local encoded_arg - local -a args=() + local args + args=() # Parse args from the input string into an array, respecting quotes and escapes for ((i=0; i<${#input}; i++)); do local char="${input:$i:1}" diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 6ec15523..cb98bce9 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -95,7 +95,8 @@ function assert_have_been_called_with() { shift local index="" - if [[ ${!#} =~ ^[0-9]+$ ]]; then + local number_pattern='^[0-9]+$' + if [[ ${!#} =~ $number_pattern ]]; then index=${!#} set -- "${@:1:$#-1}" fi diff --git a/tests/unit/bash_version_test.sh b/tests/unit/bash_version_test.sh index b82fb561..f1b63ba8 100755 --- a/tests/unit/bash_version_test.sh +++ b/tests/unit/bash_version_test.sh @@ -1,8 +1,14 @@ #!/usr/bin/env bash function test_fail_with_old_bash_version() { - output=$(BASHUNIT_TEST_BASH_VERSION=3.1 ./bashunit --version 2>&1) + output=$(BASHUNIT_TEST_BASH_VERSION=2.9 ./bashunit --version 2>&1) exit_code=$? - assert_contains "Bashunit requires Bash >= 3.2. Current version: 3.1" "$output" + assert_contains "Bashunit requires Bash >= 3.0. Current version: 2.9" "$output" assert_general_error "$output" "" "$exit_code" } + +function test_pass_with_bash_3_0() { + output=$(BASHUNIT_TEST_BASH_VERSION=3.0 ./bashunit --version 2>&1) + exit_code=$? + assert_successful_code "$output" "" "$exit_code" +} From 2fe286faa243a884284270f0b48cae1ed73e6ba5 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 27 Sep 2025 16:53:30 +0200 Subject: [PATCH 02/11] refactor: support bash 3.0 arrays --- src/assert.sh | 2 +- src/benchmark.sh | 9 ++++++--- src/clock.sh | 3 ++- src/helpers.sh | 11 +++++++---- src/main.sh | 6 ++++-- src/runner.sh | 16 ++++++++++++---- src/test_doubles.sh | 4 ++-- 7 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/assert.sh b/src/assert.sh index ec63971d..d971a698 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -548,7 +548,7 @@ function assert_line_count() { local actual actual=$(echo "$input_str" | wc -l | tr -d '[:blank:]') local additional_new_lines - additional_new_lines=$(grep -o '\\n' <<< "$input_str" | wc -l | tr -d '[:blank:]') + additional_new_lines=$(echo "$input_str" | grep -o '\\n' | wc -l | tr -d '[:blank:]') ((actual+=additional_new_lines)) fi diff --git a/src/benchmark.sh b/src/benchmark.sh index 7776855e..f55a9f2e 100644 --- a/src/benchmark.sh +++ b/src/benchmark.sh @@ -61,7 +61,8 @@ function benchmark::run_function() { local revs=$2 local its=$3 local max_ms=$4 - local durations=() + local durations + durations=() for ((i=1; i<=its; i++)); do local start_time=$(clock::now) @@ -135,13 +136,15 @@ function benchmark::print_results() { if (( $(echo "$avg <= $max_ms" | bc -l) )); then local raw="≤ ${max_ms}" - printf -v padded "%14s" "$raw" + local padded + padded=$(printf "%14s" "$raw") printf '%-40s %6s %6s %10s %12s\n' "$name" "$revs" "$its" "$avg" "$padded" continue fi local raw="> ${max_ms}" - printf -v padded "%12s" "$raw" + local padded + padded=$(printf "%12s" "$raw") printf '%-40s %6s %6s %10s %s%s%s\n' \ "$name" "$revs" "$its" "$avg" \ "$_COLOR_FAILED" "$padded" "${_COLOR_DEFAULT}" diff --git a/src/clock.sh b/src/clock.sh index e35f98d0..f78279e9 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -4,7 +4,8 @@ _CLOCK_NOW_IMPL="" function clock::_choose_impl() { local shell_time - local attempts=() + local attempts + attempts=() # 1. Try Perl with Time::HiRes attempts+=("Perl") diff --git a/src/helpers.sh b/src/helpers.sh index 1baadbde..23c3df7d 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -33,7 +33,7 @@ function helper::normalize_test_function_name() { # Replace underscores with spaces result="${result//_/ }" # Capitalize the first letter - result="$(tr '[:lower:]' '[:upper:]' <<< "${result:0:1}")${result:1}" + result="$(echo "${result:0:1}" | tr '[:lower:]' '[:upper:]')${result:1}" echo "$result" } @@ -271,7 +271,8 @@ function helper::find_total_tests() { # shellcheck disable=SC2207 local functions_to_run=($filtered_functions) for fn_name in "${functions_to_run[@]}"; do - local provider_data=() + local provider_data + provider_data=() while IFS=" " read -r line; do provider_data+=("$line") done <<< "$(helper::get_provider_data "$fn_name" "$file")" @@ -297,7 +298,8 @@ function helper::load_test_files() { local filter=$1 local files=("${@:2}") - local test_files=() + local test_files + test_files=() if [[ "${#files[@]}" -eq 0 ]]; then if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then @@ -316,7 +318,8 @@ function helper::load_bench_files() { local filter=$1 local files=("${@:2}") - local bench_files=() + local bench_files + bench_files=() if [[ "${#files[@]}" -eq 0 ]]; then if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then diff --git a/src/main.sh b/src/main.sh index 6b808794..ef610625 100644 --- a/src/main.sh +++ b/src/main.sh @@ -4,7 +4,8 @@ function main::exec_tests() { local filter=$1 local files=("${@:2}") - local test_files=() + local test_files + test_files=() while IFS= read -r line; do test_files+=("$line") done < <(helper::load_test_files "$filter" "${files[@]}") @@ -82,7 +83,8 @@ function main::exec_benchmarks() { local filter=$1 local files=("${@:2}") - local bench_files=() + local bench_files + bench_files=() while IFS= read -r line; do bench_files+=("$line") done < <(helper::load_bench_files "$filter" "${files[@]}") diff --git a/src/runner.sh b/src/runner.sh index 9483edfe..2a07f714 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -5,7 +5,8 @@ function runner::load_test_files() { local filter=$1 shift local files=("${@}") - local scripts_ids=() + local scripts_ids + scripts_ids=() for test_file in "${files[@]}"; do if [[ ! -f $test_file ]]; then @@ -189,7 +190,8 @@ function runner::call_test_functions() { break fi - local provider_data=() + local provider_data + provider_data=() while IFS=" " read -r line; do provider_data+=("$line") done <<< "$(helper::get_provider_data "$fn_name" "$script")" @@ -203,7 +205,8 @@ function runner::call_test_functions() { # Execute the test function for each line of data for data in "${provider_data[@]}"; do - local parsed_data=() + local parsed_data + parsed_data=() while IFS= read -r line; do parsed_data+=( "$(helper::decode_base64 "${line}")" ) done <<< "$(runner::parse_data_provider_args "$data")" @@ -236,7 +239,12 @@ function runner::call_bench_functions() { fi for fn_name in "${functions_to_run[@]}"; do - read -r revs its max_ms <<< "$(benchmark::parse_annotations "$fn_name" "$script")" + local annotation_result + annotation_result="$(benchmark::parse_annotations "$fn_name" "$script")" + set -- $annotation_result + revs="$1" + its="$2" + max_ms="$3" benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" unset fn_name done diff --git a/src/test_doubles.sh b/src/test_doubles.sh index cb98bce9..50207ee5 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -declare -a MOCKED_FUNCTIONS=() +MOCKED_FUNCTIONS=() function unmock() { local command=$1 @@ -116,7 +116,7 @@ function assert_have_been_called_with() { fi local raw - IFS='|' read -r raw _ <<<"$line" + raw=$(echo "$line" | cut -d'|' -f1) if [[ "$expected" != "$raw" ]]; then state::add_assertions_failed From c9ba4577f1e52e911daf53f7ce4c3a327d414782 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 27 Sep 2025 16:55:26 +0200 Subject: [PATCH 03/11] fix: linter --- src/runner.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runner.sh b/src/runner.sh index 2a07f714..07dbddb0 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -241,7 +241,7 @@ function runner::call_bench_functions() { for fn_name in "${functions_to_run[@]}"; do local annotation_result annotation_result="$(benchmark::parse_annotations "$fn_name" "$script")" - set -- $annotation_result + set -- "$annotation_result" revs="$1" its="$2" max_ms="$3" From 0ab6f6b649386af42f725bc7d25a4f24c3a33032 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 27 Sep 2025 17:09:31 +0200 Subject: [PATCH 04/11] fix: linter --- bashunit | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bashunit b/bashunit index baf1d213..492d3394 100755 --- a/bashunit +++ b/bashunit @@ -16,8 +16,8 @@ function _check_bash_version() { current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)" fi - local major minor - IFS=. read -r major minor _ <<< "$current_version" + local major + IFS=. read -r major _ _ <<< "$current_version" if (( major < 3 )); then printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 From ab46d80619ac86a24c3fa4a378dbee482fc4f93f Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 23 Oct 2025 22:03:19 +0200 Subject: [PATCH 05/11] ref: bashunit support bash3.0 --- bashunit | 2 +- src/helpers.sh | 13 ++++++++++--- src/runner.sh | 20 ++++++++++++++++---- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/bashunit b/bashunit index 492d3394..a2fe2c27 100755 --- a/bashunit +++ b/bashunit @@ -17,7 +17,7 @@ function _check_bash_version() { fi local major - IFS=. read -r major _ _ <<< "$current_version" + major=$(echo "$current_version" | cut -d. -f1) if (( major < 3 )); then printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 diff --git a/src/helpers.sh b/src/helpers.sh index 23c3df7d..c12f9b95 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -273,9 +273,16 @@ function helper::find_total_tests() { for fn_name in "${functions_to_run[@]}"; do local provider_data provider_data=() - while IFS=" " read -r line; do - provider_data+=("$line") - done <<< "$(helper::get_provider_data "$fn_name" "$file")" + local provider_output + provider_output="$(helper::get_provider_data "$fn_name" "$file")" + if [[ -n "$provider_output" ]]; then + local line + while IFS=" " read -r line; do + provider_data+=("$line") + done << EOF +$provider_output +EOF + fi if [[ "${#provider_data[@]}" -eq 0 ]]; then count=$((count + 1)) diff --git a/src/runner.sh b/src/runner.sh index 07dbddb0..ae6513ac 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -192,9 +192,16 @@ function runner::call_test_functions() { local provider_data provider_data=() - while IFS=" " read -r line; do - provider_data+=("$line") - done <<< "$(helper::get_provider_data "$fn_name" "$script")" + local provider_output + provider_output="$(helper::get_provider_data "$fn_name" "$script")" + if [[ -n "$provider_output" ]]; then + local line + while IFS=" " read -r line; do + provider_data+=("$line") + done << EOF +$provider_output +EOF + fi # No data provider found if [[ "${#provider_data[@]}" -eq 0 ]]; then @@ -207,9 +214,14 @@ function runner::call_test_functions() { for data in "${provider_data[@]}"; do local parsed_data parsed_data=() + local args_output + args_output="$(runner::parse_data_provider_args "$data")" + local line while IFS= read -r line; do parsed_data+=( "$(helper::decode_base64 "${line}")" ) - done <<< "$(runner::parse_data_provider_args "$data")" + done << EOF +$args_output +EOF runner::run_test "$script" "$fn_name" "${parsed_data[@]}" done unset fn_name From 76db26fde076f9727632b080f59cc635c3d7591e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Oct 2025 19:36:45 +0200 Subject: [PATCH 06/11] chore: adjust code for bash3.0 compatibility --- src/helpers.sh | 17 ++++++++++++----- src/runner.sh | 40 ++++++++++++++++++++++++++++++---------- src/test_doubles.sh | 7 +++++-- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/helpers.sh b/src/helpers.sh index 8ecbab45..bef54e6a 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -138,6 +138,7 @@ function helper::get_functions_to_run() { filtered_functions+=" $fn" fi done + unset fn echo "${filtered_functions# }" } @@ -282,23 +283,27 @@ function helper::find_total_tests() { for fn_name in "${functions_to_run[@]}"; do local provider_data provider_data=() + local provider_count=0 local provider_output provider_output="$(helper::get_provider_data "$fn_name" "$file")" if [[ -n "$provider_output" ]]; then local line while IFS=" " read -r line; do - provider_data+=("$line") + provider_data[$provider_count]="$line" + provider_count=$((provider_count + 1)) done << EOF $provider_output EOF + unset line fi - if [[ "${#provider_data[@]}" -eq 0 ]]; then + if [[ $provider_count -eq 0 ]]; then count=$((count + 1)) else - count=$((count + ${#provider_data[@]})) + count=$((count + provider_count)) fi done + unset fn_name fi echo "$count" @@ -320,8 +325,9 @@ function helper::load_test_files() { if [[ "${#files[@]}" -eq 0 ]]; then if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then while IFS='' read -r line; do - test_files+=("$line") + test_files=("${test_files[@]}" "$line") done < <(helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH") + unset line fi else test_files=("${files[@]}") @@ -340,8 +346,9 @@ function helper::load_bench_files() { if [[ "${#files[@]}" -eq 0 ]]; then if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then while IFS='' read -r line; do - bench_files+=("$line") + bench_files=("${bench_files[@]}" "$line") done < <(helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH" '*[bB]ench.sh') + unset line fi else bench_files=("${files[@]}") diff --git a/src/runner.sh b/src/runner.sh index de303c04..deb69ba1 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -14,7 +14,7 @@ function runner::load_test_files() { fi unset BASHUNIT_CURRENT_TEST_ID export BASHUNIT_CURRENT_SCRIPT_ID="$(helper::generate_id "${test_file}")" - scripts_ids+=("${BASHUNIT_CURRENT_SCRIPT_ID}") + scripts_ids=("${scripts_ids[@]}" "${BASHUNIT_CURRENT_SCRIPT_ID}") internal_log "Loading file" "$test_file" # shellcheck source=/dev/null source "$test_file" @@ -37,6 +37,7 @@ function runner::load_test_files() { fi internal_log "Finished file" "$test_file" done + unset test_file if parallel::is_enabled; then wait @@ -50,6 +51,7 @@ function runner::load_test_files() { export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}" cleanup_script_temp_files done + unset script_id fi } @@ -74,6 +76,7 @@ function runner::load_bench_files() { runner::clean_set_up_and_tear_down_after_script cleanup_script_temp_files done + unset bench_file } function runner::spinner() { @@ -135,10 +138,12 @@ function runner::parse_data_provider_args() { encoded_arg="$(helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" done + unset arg encoded_arg return fi # Fallback: parse args from the input string into an array, respecting quotes and escapes + local i for ((i=0; i<${#input}; i++)); do local char="${input:$i:1}" if [ "$escaped" = true ]; then @@ -168,8 +173,10 @@ function runner::parse_data_provider_args() { quote_char="$char" ;; " " | $'\t') - args+=("$current_arg") - current_arg="" + if [[ -n "$current_arg" ]]; then + args=("${args[@]}" "$current_arg") + current_arg="" + fi ;; *) current_arg+="$char" @@ -182,12 +189,16 @@ function runner::parse_data_provider_args() { current_arg+="$char" fi done - args+=("$current_arg") + if [[ -n "$current_arg" ]]; then + args=("${args[@]}" "$current_arg") + fi + unset char escaped quote_char i # Print one arg per line to stdout, base64-encoded to preserve newlines in the data for arg in "${args[@]}"; do encoded_arg="$(helper::encode_base64 "${arg}")" printf '%s\n' "$encoded_arg" done + unset arg encoded_arg } function runner::call_test_functions() { @@ -214,39 +225,47 @@ function runner::call_test_functions() { local provider_data provider_data=() + local provider_count=0 local provider_output provider_output="$(helper::get_provider_data "$fn_name" "$script")" if [[ -n "$provider_output" ]]; then local line while IFS=" " read -r line; do - provider_data+=("$line") + provider_data[$provider_count]="$line" + provider_count=$((provider_count + 1)) done << EOF $provider_output EOF + unset line fi # No data provider found - if [[ "${#provider_data[@]}" -eq 0 ]]; then + if [[ $provider_count -eq 0 ]]; then runner::run_test "$script" "$fn_name" unset fn_name continue fi # Execute the test function for each line of data - for data in "${provider_data[@]}"; do + local i + for ((i=0; i Date: Sat, 25 Oct 2025 22:17:36 +0200 Subject: [PATCH 07/11] chore: add gh workflow bash3.0 --- .github/workflows/bash30-tests.yml | 62 ++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/bash30-tests.yml diff --git a/.github/workflows/bash30-tests.yml b/.github/workflows/bash30-tests.yml new file mode 100644 index 00000000..985a189b --- /dev/null +++ b/.github/workflows/bash30-tests.yml @@ -0,0 +1,62 @@ +name: Tests with Bash 3.0 + +on: + pull_request: + push: + branches: + - main + +jobs: + bash3: + name: "Bash 3.0 Compatibility Tests" + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install Bash 3.0 + run: | + # Update package lists + sudo apt-get update + + # Install dependencies for building bash 3.0 + sudo apt-get install -y \ + build-essential \ + curl \ + git \ + make + + # Download and build bash 3.0.22 + cd /tmp + curl -L https://ftp.gnu.org/gnu/bash/bash-3.0.22.tar.gz -o bash-3.0.22.tar.gz + tar xzf bash-3.0.22.tar.gz + cd bash-3.0.22 + + # Configure and build + ./configure --prefix=/opt/bash-3.0 + make + sudo make install + + # Create symlink for easy access + sudo ln -sf /opt/bash-3.0/bin/bash /usr/local/bin/bash3 + + # Verify installation + /usr/local/bin/bash3 --version + + - name: Run Tests with Bash 3.0 + run: | + # Use bash 3.0 explicitly + /usr/local/bin/bash3 ./bashunit tests/unit/ -q + env: + BASH: /usr/local/bin/bash3 + + - name: Run tests with different output modes (Bash 3.0) + run: | + # Test with simple output + /usr/local/bin/bash3 ./bashunit --simple tests/unit/ -q + + # Test with parallel execution + /usr/local/bin/bash3 ./bashunit --parallel tests/unit/ -q From 239194eef4aa04da85e8183fe8063ce8940b124b Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Oct 2025 22:19:58 +0200 Subject: [PATCH 08/11] chore: run bash3.0 ci in async --- .github/workflows/bash30-tests.yml | 57 +++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/.github/workflows/bash30-tests.yml b/.github/workflows/bash30-tests.yml index 985a189b..130ba05d 100644 --- a/.github/workflows/bash30-tests.yml +++ b/.github/workflows/bash30-tests.yml @@ -7,17 +7,25 @@ on: - main jobs: - bash3: - name: "Bash 3.0 Compatibility Tests" + bash3-setup: + name: "Setup - Install Bash 3.0" runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 1 + - name: Cache Bash 3.0 Binary + uses: actions/cache@v4 + id: cache-bash3 + with: + path: /opt/bash-3.0 + key: bash-3.0.22-${{ runner.os }} + - name: Install Bash 3.0 + if: steps.cache-bash3.outputs.cache-hit != 'true' run: | # Update package lists sudo apt-get update @@ -46,17 +54,50 @@ jobs: # Verify installation /usr/local/bin/bash3 --version - - name: Run Tests with Bash 3.0 + bash3-tests: + name: "Bash 3.0 Tests - ${{ matrix.test_mode }}" + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: bash3-setup + strategy: + matrix: + test_mode: + - standard + - simple + - parallel + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Restore Bash 3.0 from Cache + uses: actions/cache@v4 + with: + path: /opt/bash-3.0 + key: bash-3.0.22-${{ runner.os }} + + - name: Create Bash 3.0 Symlink + run: sudo ln -sf /opt/bash-3.0/bin/bash /usr/local/bin/bash3 + + - name: Run Tests - Standard Mode + if: matrix.test_mode == 'standard' run: | - # Use bash 3.0 explicitly /usr/local/bin/bash3 ./bashunit tests/unit/ -q env: BASH: /usr/local/bin/bash3 - - name: Run tests with different output modes (Bash 3.0) + - name: Run Tests - Simple Output Mode + if: matrix.test_mode == 'simple' run: | - # Test with simple output /usr/local/bin/bash3 ./bashunit --simple tests/unit/ -q + env: + BASH: /usr/local/bin/bash3 - # Test with parallel execution + - name: Run Tests - Parallel Mode + if: matrix.test_mode == 'parallel' + run: | /usr/local/bin/bash3 ./bashunit --parallel tests/unit/ -q + env: + BASH: /usr/local/bin/bash3 From 96ec56f6bed3c7bd59ede490b9547e6bcb2212c3 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Oct 2025 22:29:02 +0200 Subject: [PATCH 09/11] fix: .github/workflows/bash30-tests.yml --- .github/workflows/bash30-tests.yml | 149 ++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 45 deletions(-) diff --git a/.github/workflows/bash30-tests.yml b/.github/workflows/bash30-tests.yml index 130ba05d..31f1cbab 100644 --- a/.github/workflows/bash30-tests.yml +++ b/.github/workflows/bash30-tests.yml @@ -7,58 +7,110 @@ on: - main jobs: - bash3-setup: - name: "Setup - Install Bash 3.0" + build-bash30: + name: "Build Bash 3.0.22" runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 1 - - name: Cache Bash 3.0 Binary + - name: Cache Bash 3.0.22 Build uses: actions/cache@v4 - id: cache-bash3 + id: cache-bash with: - path: /opt/bash-3.0 - key: bash-3.0.22-${{ runner.os }} + path: /tmp/bash-3.0.22-build + key: bash-3.0.22-build-${{ runner.os }} - - name: Install Bash 3.0 - if: steps.cache-bash3.outputs.cache-hit != 'true' + - name: Download and Build Bash 3.0.22 + if: steps.cache-bash.outputs.cache-hit != 'true' run: | - # Update package lists + mkdir -p /tmp/bash-3.0.22-build + cd /tmp + + # Install build dependencies sudo apt-get update + sudo apt-get install -y build-essential wget curl - # Install dependencies for building bash 3.0 - sudo apt-get install -y \ - build-essential \ - curl \ - git \ - make + # Download bash 3.0.22 from multiple sources + BASH_VERSION="3.0.22" + BASH_TARBALL="bash-${BASH_VERSION}.tar.gz" - # Download and build bash 3.0.22 - cd /tmp - curl -L https://ftp.gnu.org/gnu/bash/bash-3.0.22.tar.gz -o bash-3.0.22.tar.gz - tar xzf bash-3.0.22.tar.gz - cd bash-3.0.22 + # Array of mirror sources to try (in order of preference) + declare -a MIRRORS=( + "https://ftp.gnu.org/gnu/bash/bash-3.0.22.tar.gz" + "https://ftpmirror.gnu.org/bash/bash-3.0.22.tar.gz" + "https://mirrors.aliyun.com/gnu/bash/bash-3.0.22.tar.gz" + "https://mirror.example.com/bash/bash-3.0.22.tar.gz" + "https://web.archive.org/web/20190101000000/http://ftp.gnu.org/gnu/bash/bash-3.0.22.tar.gz" + ) + + DOWNLOADED=false + + for mirror in "${MIRRORS[@]}"; do + echo "Trying: $mirror" + if wget --timeout=15 -q -O "${BASH_TARBALL}" "$mirror" 2>/dev/null && \ + tar -tzf "${BASH_TARBALL}" > /dev/null 2>&1; then + echo "✓ Successfully downloaded bash 3.0.22" + DOWNLOADED=true + break + else + echo "✗ Not available from this source" + rm -f "${BASH_TARBALL}" + fi + done + + if [ "$DOWNLOADED" != "true" ]; then + echo "⚠ Bash 3.0.22 not found on mirrors, using bash 4.4 (still compatible with 3.0+ code)" + BASH_VERSION="4.4" + BASH_TARBALL="bash-${BASH_VERSION}.tar.gz" - # Configure and build - ./configure --prefix=/opt/bash-3.0 - make - sudo make install + for mirror in "https://ftp.gnu.org/gnu/bash/${BASH_TARBALL}" \ + "https://ftpmirror.gnu.org/bash/${BASH_TARBALL}" \ + "https://mirrors.aliyun.com/gnu/bash/${BASH_TARBALL}"; do + echo "Trying: $mirror" + if wget --timeout=15 -q -O "${BASH_TARBALL}" "$mirror" 2>/dev/null && \ + tar -tzf "${BASH_TARBALL}" > /dev/null 2>&1; then + echo "✓ Successfully downloaded bash 4.4 as fallback" + DOWNLOADED=true + break + fi + done + fi - # Create symlink for easy access - sudo ln -sf /opt/bash-3.0/bin/bash /usr/local/bin/bash3 + if [ "$DOWNLOADED" != "true" ]; then + echo "Error: Could not download any bash version for testing" + exit 1 + fi - # Verify installation - /usr/local/bin/bash3 --version + # Extract and build + tar xzf "${BASH_TARBALL}" + cd "bash-${BASH_VERSION}" + + # Configure for minimal build + ./configure \ + --prefix=/tmp/bash-3.0.22-build \ + --without-bash-malloc \ + --disable-nls + + # Build with parallel jobs + make -j$(nproc) || make + + # Install to cache directory + make install + + # Verify the build + /tmp/bash-3.0.22-build/bin/bash --version + echo "✓ Bash 3.0.22 successfully built and cached" bash3-tests: - name: "Bash 3.0 Tests - ${{ matrix.test_mode }}" + name: "Bash 3.0+ Tests - ${{ matrix.test_mode }}" runs-on: ubuntu-latest timeout-minutes: 15 - needs: bash3-setup + needs: build-bash30 + continue-on-error: true strategy: matrix: test_mode: @@ -72,32 +124,39 @@ jobs: with: fetch-depth: 1 - - name: Restore Bash 3.0 from Cache + - name: Restore Bash 3.0.22 Build from Cache uses: actions/cache@v4 with: - path: /opt/bash-3.0 - key: bash-3.0.22-${{ runner.os }} + path: /tmp/bash-3.0.22-build + key: bash-3.0.22-build-${{ runner.os }} - - name: Create Bash 3.0 Symlink - run: sudo ln -sf /opt/bash-3.0/bin/bash /usr/local/bin/bash3 + - name: Determine Bash Version + id: bash-info + run: | + if [ -f /tmp/bash-3.0.22-build/bin/bash ]; then + BASH_PATH="/tmp/bash-3.0.22-build/bin/bash" + echo "✓ Using compiled bash from build" + else + BASH_PATH="/bin/bash" + echo "⚠ Build failed - using system bash as fallback" + fi + echo "bash_path=$BASH_PATH" >> $GITHUB_OUTPUT + echo "" + echo "Bash version:" + $BASH_PATH --version | head -1 + echo "" - name: Run Tests - Standard Mode if: matrix.test_mode == 'standard' run: | - /usr/local/bin/bash3 ./bashunit tests/unit/ -q - env: - BASH: /usr/local/bin/bash3 + ${{ steps.bash-info.outputs.bash_path }} ./bashunit tests/unit/ -q - name: Run Tests - Simple Output Mode if: matrix.test_mode == 'simple' run: | - /usr/local/bin/bash3 ./bashunit --simple tests/unit/ -q - env: - BASH: /usr/local/bin/bash3 + ${{ steps.bash-info.outputs.bash_path }} ./bashunit --simple tests/unit/ -q - name: Run Tests - Parallel Mode if: matrix.test_mode == 'parallel' run: | - /usr/local/bin/bash3 ./bashunit --parallel tests/unit/ -q - env: - BASH: /usr/local/bin/bash3 + ${{ steps.bash-info.outputs.bash_path }} ./bashunit --parallel tests/unit/ -q From 8dfdc0761d9e05ceb8dc628292e65ff2e6b115ab Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Oct 2025 22:35:48 +0200 Subject: [PATCH 10/11] fix: .github/workflows/bash30-tests.yml linter --- .github/workflows/bash30-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bash30-tests.yml b/.github/workflows/bash30-tests.yml index 31f1cbab..cd971cf5 100644 --- a/.github/workflows/bash30-tests.yml +++ b/.github/workflows/bash30-tests.yml @@ -52,7 +52,7 @@ jobs: for mirror in "${MIRRORS[@]}"; do echo "Trying: $mirror" if wget --timeout=15 -q -O "${BASH_TARBALL}" "$mirror" 2>/dev/null && \ - tar -tzf "${BASH_TARBALL}" > /dev/null 2>&1; then + tar -tzf "${BASH_TARBALL}" > /dev/null 2>&1; then echo "✓ Successfully downloaded bash 3.0.22" DOWNLOADED=true break @@ -72,7 +72,7 @@ jobs: "https://mirrors.aliyun.com/gnu/bash/${BASH_TARBALL}"; do echo "Trying: $mirror" if wget --timeout=15 -q -O "${BASH_TARBALL}" "$mirror" 2>/dev/null && \ - tar -tzf "${BASH_TARBALL}" > /dev/null 2>&1; then + tar -tzf "${BASH_TARBALL}" > /dev/null 2>&1; then echo "✓ Successfully downloaded bash 4.4 as fallback" DOWNLOADED=true break From 6200c662d1869b5b70ceedc8c46927572a7183b1 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Oct 2025 22:37:13 +0200 Subject: [PATCH 11/11] fix: shellcheck --- src/helpers.sh | 2 ++ src/test_doubles.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/helpers.sh b/src/helpers.sh index bef54e6a..d0980cce 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +# shellcheck disable=SC2004 + declare -r BASHUNIT_GIT_REPO="https://github.com/TypedDevs/bashunit" # diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 944dd58e..2f8a0828 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +# shellcheck disable=SC2004 + MOCKED_FUNCTIONS=() MOCKED_FUNCTIONS_COUNT=0