diff --git a/CHANGELOG.md b/CHANGELOG.md index afe4b4a3..43dfa3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Changed +- Faster test execution: removed per-assertion and per-result subprocess forks on hot paths (test-function-name detection, ANSI stripping in `assert_equals`, spy index parsing, `file:line` filter parsing, result-line padding). Behaviour is unchanged + ## [0.40.0](https://github.com/TypedDevs/bashunit/compare/0.39.1...0.40.0) - 2026-06-16 ### Added diff --git a/src/assert.sh b/src/assert.sh index 35beb171..1a23a02a 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -254,11 +254,6 @@ function assert_contains() { local -a actual_arr actual_arr=("${@:2}") local label_override="" - local label_override="" - local label_override="" - local label_override="" - local label_override="" - local label_override="" local actual actual=$(printf '%s\n' "${actual_arr[@]}") @@ -811,11 +806,19 @@ function assert_line_count() { if [ -z "$input_str" ]; then local actual=0 else - 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:]') - actual=$((actual + additional_new_lines)) + # Count lines without forking: one line plus each real newline, plus each + # literal "\n" (backslash-n) escape, which counts as an extra line break. + local actual=1 + local _rest="$input_str" + while [ "$_rest" != "${_rest#*$'\n'}" ]; do + _rest="${_rest#*$'\n'}" + actual=$((actual + 1)) + done + _rest="$input_str" + while [ "$_rest" != "${_rest#*\\n}" ]; do + _rest="${_rest#*\\n}" + actual=$((actual + 1)) + done fi if [ "$expected" != "$actual" ]; then diff --git a/src/clock.sh b/src/clock.sh index 51641a71..f3e8cb8b 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -22,11 +22,15 @@ function bashunit::clock::_choose_impl() { if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then local result result=$(date +%s%N 2>/dev/null) - if [ "$(echo "$result" | "$GREP" -cv 'N' || true)" -gt 0 ] \ - && [ "$(echo "$result" | "$GREP" -cE '^[0-9]+$' || true)" -gt 0 ]; then + # A pure-digit result means %N expanded; a literal "N" (unsupported date) + # contains a non-digit, so the digits-only check alone is sufficient. + case "$result" in + '' | *[!0-9]*) ;; + *) _BASHUNIT_CLOCK_NOW_IMPL="date" return 0 - fi + ;; + esac fi # 3. Try Perl with Time::HiRes diff --git a/src/console_results.sh b/src/console_results.sh index 2d4a8c7a..c3149814 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -261,7 +261,7 @@ function bashunit::console_results::print_successful_test() { else time_display="${duration}ms" fi - full_line="$(printf "%s\n" "$(bashunit::str::rpad "$line" "$time_display")")" + full_line="$(bashunit::str::rpad "$line" "$time_display")" fi bashunit::state::print_line "successful" "$full_line" @@ -471,7 +471,7 @@ function bashunit::console_results::print_risky_test() { if bashunit::env::is_show_execution_time_enabled; then local time_display time_display=$(bashunit::console_results::format_duration "$duration") - full_line="$(printf "%s\n" "$(bashunit::str::rpad "$line" "$time_display")")" + full_line="$(bashunit::str::rpad "$line" "$time_display")" fi bashunit::state::print_line "risky" "$full_line" diff --git a/src/helpers.sh b/src/helpers.sh index 0eb9645e..89dad828 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -16,14 +16,14 @@ function bashunit::helper::find_test_function_name() { local i for ((i = 0; i < ${#FUNCNAME[@]}; i++)); do local fn="${FUNCNAME[$i]}" - # Check if function starts with "test_" or "test" followed by uppercase - local _re='^test[A-Z]' - local _is_test=false - case "$fn" in test_*) _is_test=true ;; esac - if [ "$_is_test" = true ] || [ "$(echo "$fn" | "$GREP" -cE "$_re" || true)" -gt 0 ]; then + # Check if function starts with "test_" or "test" followed by uppercase. + # Pure-bash globs avoid forking echo+grep on every call-stack frame (hot path). + case "$fn" in + test_* | test[A-Z]*) echo "$fn" return - fi + ;; + esac done # No test function found, use fallback (caller of the assertion) # FUNCNAME[0] = bashunit::helper::find_test_function_name @@ -100,6 +100,17 @@ function bashunit::helper::escape_single_quotes() { function bashunit::helper::interpolate_function_name() { local function_name="$1" shift + + # Placeholders look like "::N::", so a name without "::" can never interpolate. + # Short-circuit to skip the per-arg escape_single_quotes forks in that case. + case "$function_name" in + *::*) ;; + *) + echo "$function_name" + return + ;; + esac + local -a args local args_count=$# args=("$@") @@ -483,17 +494,25 @@ function bashunit::helper::parse_file_path_filter() { filter="${input#*::}" ;; *) - # Check for :number syntax (line number filter) - local _re='^(.+):([0-9]+)$' - if [ "$(echo "$input" | "$GREP" -cE "$_re" || true)" -gt 0 ]; then - file_path=$(echo "$input" | sed -nE 's/^(.+):([0-9]+)$/\1/p') - local line_number - line_number=$(echo "$input" | sed -nE 's/^(.+):([0-9]+)$/\2/p') - # Line number will be resolved to function name later - filter="__line__:${line_number}" - else + # Check for :number syntax (line number filter): a non-empty path, a + # colon, then digits to the end of string. Pure-bash parameter expansion + # avoids forking grep+sed. + local line_number="${input##*:}" + local maybe_path="${input%:*}" + case "$line_number" in + '' | *[!0-9]*) file_path="$input" - fi + ;; + *) + if [ -n "$maybe_path" ] && [ "$maybe_path" != "$input" ]; then + # Line number will be resolved to function name later + file_path="$maybe_path" + filter="__line__:${line_number}" + else + file_path="$input" + fi + ;; + esac ;; esac diff --git a/src/runner.sh b/src/runner.sh index b47bf547..dd91eb42 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -89,9 +89,22 @@ function bashunit::runner::resolve_test_location() { fi } +# Writes the interpolated test-function name into _BASHUNIT_RUNNER_INTERP_OUT. +# Arguments: $1 fn_name, $@ test arguments function bashunit::runner::apply_interpolated_title() { local fn_name=$1 shift + + # Only "::N::"-style names interpolate; skip the capture fork for the rest. + case "$fn_name" in + *::*) ;; + *) + bashunit::state::reset_current_test_interpolated_function_name + _BASHUNIT_RUNNER_INTERP_OUT=$fn_name + return + ;; + esac + local interpolated interpolated="$(bashunit::helper::interpolate_function_name "$fn_name" "$@")" if [ "$interpolated" != "$fn_name" ]; then @@ -99,7 +112,7 @@ function bashunit::runner::apply_interpolated_title() { else bashunit::state::reset_current_test_interpolated_function_name fi - printf '%s' "$interpolated" + _BASHUNIT_RUNNER_INTERP_OUT=$interpolated } # Hot-path result helpers below return their value via a dedicated global slot @@ -117,6 +130,7 @@ _BASHUNIT_RUNNER_FIELD_OUT="" _BASHUNIT_RUNNER_TOTAL_OUT="" _BASHUNIT_RUNNER_TYPE_OUT="" _BASHUNIT_RUNNER_OUTPUT_OUT="" +_BASHUNIT_RUNNER_INTERP_OUT="" # Writes the value of an encoded field (##KEY=value##) into _BASHUNIT_RUNNER_FIELD_OUT. # Arguments: $1 test_execution_result, $2 key @@ -997,8 +1011,8 @@ function bashunit::runner::run_test() { bashunit::runner::export_test_identity "$test_file" "$fn_name" bashunit::state::reset_test_title - local interpolated_fn_name - interpolated_fn_name=$(bashunit::runner::apply_interpolated_title "$fn_name" "$@") + 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" diff --git a/src/str.sh b/src/str.sh index b63232eb..6a969651 100644 --- a/src/str.sh +++ b/src/str.sh @@ -3,6 +3,16 @@ # Strip ANSI escape codes and control characters function bashunit::str::strip_ansi() { local input="$1" + # Fast path: plain text with no backslash (echo -e no-op) and no control + # bytes (nothing for sed to strip) passes through unchanged. Avoids forking + # echo+sed on the common case, which runs twice per assert_equals. + case "$input" in + *\\* | *[[:cntrl:]]*) ;; + *) + echo -e "$input" + return + ;; + esac echo -e "$input" | sed -E 's/\x1B\[[0-9;]*[mK]//g; s/[[:cntrl:]]//g' } @@ -28,41 +38,42 @@ function bashunit::str::rpad() { is_truncated=true fi - # Rebuild the text with ANSI codes intact, preserving the truncation - local result_left_text="" - local i=0 - local j=0 - while [ $i -lt ${#clean_left_text} ] && [ $j -lt ${#left_text} ]; do - local char="${clean_left_text:$i:1}" - local original_char="${left_text:$j:1}" - - # If the current character is part of an ANSI sequence, skip it and copy it - if [ "$original_char" = $'\x1b' ]; then - while [ "${left_text:$j:1}" != "m" ] && [ $j -lt ${#left_text} ]; do - result_left_text="$result_left_text${left_text:$j:1}" - ((++j)) - done - result_left_text="$result_left_text${left_text:$j:1}" # Append the final 'm' - ((++j)) - elif [ "$char" = "$original_char" ]; then - # Match the actual character - result_left_text="$result_left_text$char" - ((++i)) - ((++j)) - else - ((++j)) - fi - done - + local result_left_text local remaining_space if $is_truncated; then + # Rebuild char-by-char with ANSI codes intact, applying the truncation. + result_left_text="" + local i=0 + local j=0 + while [ $i -lt ${#clean_left_text} ] && [ $j -lt ${#left_text} ]; do + local char="${clean_left_text:$i:1}" + local original_char="${left_text:$j:1}" + + # If the current character is part of an ANSI sequence, skip it and copy it + if [ "$original_char" = $'\x1b' ]; then + while [ "${left_text:$j:1}" != "m" ] && [ $j -lt ${#left_text} ]; do + result_left_text="$result_left_text${left_text:$j:1}" + ((++j)) + done + result_left_text="$result_left_text${left_text:$j:1}" # Append the final 'm' + ((++j)) + elif [ "$char" = "$original_char" ]; then + # Match the actual character + result_left_text="$result_left_text$char" + ((++i)) + ((++j)) + else + ((++j)) + fi + done result_left_text="$result_left_text..." # 1: due to a blank space # 3: due to the appended ... remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1 - 3)) else - # Copy any remaining characters after the truncation point - result_left_text="$result_left_text${left_text:$j}" + # Not truncated: the visible text fits, so the original (ANSI intact) is + # already correct — skip the per-character rebuild entirely. + result_left_text="$left_text" remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1)) fi diff --git a/src/test_doubles.sh b/src/test_doubles.sh index e9f82b7b..48810ddc 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -115,10 +115,15 @@ function assert_have_been_called_with() { shift local index="" - if [ "$(echo "${!#}" | "$GREP" -cE '^[0-9]+$' || true)" -gt 0 ]; then + # A trailing all-digits arg selects the nth recorded call. Pure-bash glob + # avoids forking echo+grep on every assertion. + case "${!#}" in + '' | *[!0-9]*) ;; + *) index=${!#} set -- "${@:1:$#-1}" - fi + ;; + esac local expected="$*" diff --git a/tests/acceptance/bashunit_cd_in_setup_before_script_test.sh b/tests/acceptance/bashunit_cd_in_setup_before_script_test.sh index 0a400641..4f94912d 100644 --- a/tests/acceptance/bashunit_cd_in_setup_before_script_test.sh +++ b/tests/acceptance/bashunit_cd_in_setup_before_script_test.sh @@ -8,10 +8,6 @@ function set_up_before_script() { TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" } -function strip_ansi() { - sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' -} - function test_subsequent_tests_run_when_set_up_before_script_changes_directory() { local first_file=./tests/acceptance/fixtures/test_cd_in_setup_before_script_first.sh local second_file=./tests/acceptance/fixtures/test_cd_in_setup_before_script_second.sh diff --git a/tests/acceptance/bashunit_hook_source_failure_test.sh b/tests/acceptance/bashunit_hook_source_failure_test.sh index 211e4deb..68359375 100644 --- a/tests/acceptance/bashunit_hook_source_failure_test.sh +++ b/tests/acceptance/bashunit_hook_source_failure_test.sh @@ -5,10 +5,6 @@ function set_up_before_script() { TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" } -function strip_ansi() { - sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' -} - function test_bashunit_when_tear_down_sources_nonexistent_file() { local test_file=./tests/acceptance/fixtures/test_bashunit_when_teardown_sources_nonexistent_file.sh diff --git a/tests/acceptance/bashunit_risky_test.sh b/tests/acceptance/bashunit_risky_test.sh index dc1126c1..b84e9a58 100644 --- a/tests/acceptance/bashunit_risky_test.sh +++ b/tests/acceptance/bashunit_risky_test.sh @@ -5,10 +5,6 @@ function set_up_before_script() { TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" } -function strip_ansi() { - sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' -} - function test_bashunit_risky_test_shows_warning() { local test_file=./tests/acceptance/fixtures/test_bashunit_risky_no_assertions.sh diff --git a/tests/acceptance/bashunit_setup_before_script_error_test.sh b/tests/acceptance/bashunit_setup_before_script_error_test.sh index b6f25071..996aa767 100644 --- a/tests/acceptance/bashunit_setup_before_script_error_test.sh +++ b/tests/acceptance/bashunit_setup_before_script_error_test.sh @@ -5,10 +5,6 @@ function set_up_before_script() { TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" } -function strip_ansi() { - sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' -} - function test_bashunit_when_set_up_before_script_errors() { local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_before_script_errors.sh local fixture=$test_file diff --git a/tests/acceptance/bashunit_setup_error_test.sh b/tests/acceptance/bashunit_setup_error_test.sh index 8f924366..dde14e3e 100644 --- a/tests/acceptance/bashunit_setup_error_test.sh +++ b/tests/acceptance/bashunit_setup_error_test.sh @@ -5,10 +5,6 @@ function set_up_before_script() { TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" } -function strip_ansi() { - sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' -} - function test_bashunit_when_set_up_errors() { local test_file=./tests/acceptance/fixtures/test_bashunit_when_setup_errors.sh local fixture=$test_file diff --git a/tests/acceptance/bashunit_syntax_error_test.sh b/tests/acceptance/bashunit_syntax_error_test.sh index 2785ed22..2f0a1f1e 100644 --- a/tests/acceptance/bashunit_syntax_error_test.sh +++ b/tests/acceptance/bashunit_syntax_error_test.sh @@ -5,10 +5,6 @@ function set_up_before_script() { TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" } -function strip_ansi() { - sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' -} - function test_bashunit_when_test_file_has_syntax_error() { local test_file=./tests/acceptance/fixtures/test_bashunit_when_syntax_error.bash diff --git a/tests/acceptance/bashunit_teardown_after_script_error_test.sh b/tests/acceptance/bashunit_teardown_after_script_error_test.sh index 955b1633..30dfb2e6 100644 --- a/tests/acceptance/bashunit_teardown_after_script_error_test.sh +++ b/tests/acceptance/bashunit_teardown_after_script_error_test.sh @@ -5,10 +5,6 @@ function set_up_before_script() { TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" } -function strip_ansi() { - sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' -} - function test_bashunit_when_tear_down_after_script_errors() { local test_file=./tests/acceptance/fixtures/test_bashunit_when_teardown_after_script_errors.sh local fixture=$test_file diff --git a/tests/acceptance/bashunit_teardown_error_test.sh b/tests/acceptance/bashunit_teardown_error_test.sh index ee65205f..07b32864 100644 --- a/tests/acceptance/bashunit_teardown_error_test.sh +++ b/tests/acceptance/bashunit_teardown_error_test.sh @@ -5,10 +5,6 @@ function set_up_before_script() { TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" } -function strip_ansi() { - sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' -} - function test_bashunit_when_tear_down_errors() { local test_file=./tests/acceptance/fixtures/test_bashunit_when_teardown_errors.sh local fixture=$test_file diff --git a/tests/bootstrap.sh b/tests/bootstrap.sh index debee88e..2322392d 100644 --- a/tests/bootstrap.sh +++ b/tests/bootstrap.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash set -euo pipefail +# Strips ANSI escape sequences from stdin. Shared by acceptance tests that +# compare rendered CLI output against plain-text expectations. +function strip_ansi() { + sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' +} + function mock_non_existing_fn() { return 127 } diff --git a/tests/unit/custom_assertions_test.sh b/tests/unit/custom_assertions_test.sh index df27fb61..71b9f0fb 100644 --- a/tests/unit/custom_assertions_test.sh +++ b/tests/unit/custom_assertions_test.sh @@ -126,6 +126,18 @@ function test_helper_find_test_function_name_from_nested_function() { assert_same "test_helper_find_test_function_name_from_nested_function" "$found_name" } +function test_helper_find_test_function_name_detects_camelcase_frame() { + # A camelCase frame (testXxx) must be detected, not just snake_case (test_xxx) + testCamelCaseInner() { + bashunit::helper::find_test_function_name + } + + local found_name + found_name="$(testCamelCaseInner)" + + assert_same "testCamelCaseInner" "$found_name" +} + function test_helper_find_test_function_name_from_deeply_nested() { # Test from deeply nested functions _level3() { diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index 08b7278e..7f55c10d 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -321,6 +321,13 @@ function test_interpolate_fn_name() { assert_same "test_name_'bar'_foo" "$result" } +function test_interpolate_fn_name_without_placeholder_ignores_args() { + local result + result="$(bashunit::helper::interpolate_function_name "test_plain_name" "bar" "baz")" + + assert_same "test_plain_name" "$result" +} + function test_normalize_test_function_name_with_interpolation() { local fn="test_returns_value_::1::_and_::2::_given" # shellcheck disable=SC2155 @@ -421,6 +428,20 @@ function test_parse_file_path_filter_with_line_number() { assert_same "__line__:42" "$filter" } +function test_parse_file_path_filter_colon_followed_by_non_number() { + local result + result=$(bashunit::helper::parse_file_path_filter "tests/unit/example_test.sh:foo") || true + + local file_path filter + { + read -r file_path || true + read -r filter || true + } <<<"$result" + + assert_same "tests/unit/example_test.sh:foo" "$file_path" + assert_same "" "$filter" +} + function test_parse_file_path_filter_with_colon_in_path() { local result result=$(bashunit::helper::parse_file_path_filter "/path/to:weird/example_test.sh::test_func") || true diff --git a/tests/unit/str_test.sh b/tests/unit/str_test.sh index 54e71a58..3839db9c 100644 --- a/tests/unit/str_test.sh +++ b/tests/unit/str_test.sh @@ -2,6 +2,41 @@ # shellcheck disable=SC2155 +function test_strip_ansi_plain_text_is_unchanged() { + assert_same "hello world" "$(bashunit::str::strip_ansi "hello world")" +} + +function test_strip_ansi_removes_color_codes() { + local colored=$(printf "\033[32mok\033[0m") + + assert_same "ok" "$(bashunit::str::strip_ansi "$colored")" +} + +function test_strip_ansi_removes_control_chars() { + local tabbed=$(printf "a\tb") + + assert_same "ab" "$(bashunit::str::strip_ansi "$tabbed")" +} + +function test_strip_ansi_empty_input() { + assert_same "" "$(bashunit::str::strip_ansi "")" +} + +function test_strip_ansi_glob_chars_are_unchanged() { + # Glob metacharacters must not trigger the control-char slow path + assert_same "a*b?c[d]" "$(bashunit::str::strip_ansi "a*b?c[d]")" +} + +function test_strip_ansi_percent_is_unchanged() { + assert_same "100% done" "$(bashunit::str::strip_ansi "100% done")" +} + +function test_strip_ansi_backslash_escape_is_expanded_then_stripped() { + # A literal backslash sends input through the slow path, where echo -e + # expands "\t" to a tab and sed then strips it as a control char + assert_same "col1col2" "$(bashunit::str::strip_ansi "col1\\tcol2")" +} + function test_rpad_default_width_padding_and_empty_left_text() { export TERMINAL_WIDTH=30