diff --git a/CHANGELOG.md b/CHANGELOG.md index a392162f..a2d42ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - `--jobs auto` / `-j auto` caps parallel concurrency at the CPU core count (portable across Linux/macOS/BSD); the default stays unlimited (#766) ### Changed +- Faster test runs: stripping ANSI codes from short colored strings is pure bash now, so aligning the per-test execution time (shown on systems with a fork-free clock, e.g. Linux) no longer forks `sed` once per passing test +- Faster parallel runs: publishing each test's result file no longer forks `basename`, `mkdir` and an `echo | tr | sed` pipeline per test — the suite dir name is parameter expansion, the dir is pre-created once per file before workers spawn, and arg sanitizing is skipped without provider args (a 10-test parallel run dropped from 61 to 21 forks). No behaviour change - Faster test runs: listing all defined functions uses the `compgen -A function` builtin instead of forking `declare -F | awk` (three call sites; 5 -> 3 `awk` forks per test file). No behaviour change - Faster test runs: ordering a file's test functions by definition line is pure bash now instead of an `awk | sort | awk` pipeline that ran twice per file, and the duplicate-function check sorts its (usually empty) result inside awk instead of piping through `sort` (9 -> 5 forks per test file). No behaviour change - Faster failure rendering: the "Source:" assert-line context of a failing test reads the test-function body in a single pass instead of forking `sed` per line and `grep` per line to find the closing brace (a 20-line body dropped from ~46 to ~2 forks here). No behaviour change diff --git a/src/math.sh b/src/math.sh index b0b69462..cc3df1eb 100644 --- a/src/math.sh +++ b/src/math.sh @@ -19,8 +19,14 @@ function bashunit::math::calculate() { ;; esac - # Remove leading zeros from integers - expr=$(echo "$expr" | sed -E 's/\b0*([1-9][0-9]*)/\1/g') + # Remove leading zeros from integers so $((...)) does not read them as octal. + # Only fork sed when a leading zero is actually present — the common callers + # (clock durations) never produce one, so the no-bc path stays fork-free. + case "$expr" in + 0[0-9]* | *[!0-9.]0[0-9]*) + expr=$(echo "$expr" | sed -E 's/\b0*([1-9][0-9]*)/\1/g') + ;; + esac local result=$((expr)) echo "$result" diff --git a/src/runner.sh b/src/runner.sh index e0572d7a..1a2d914d 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -838,6 +838,14 @@ function bashunit::runner::call_test_functions() { allow_test_parallel=false fi + # Pre-create the file's result dir before spawning test workers: they all + # publish into it, and checking `[ -d ]` inside a worker races its siblings + # (every worker would still pay the mkdir fork). + if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then + local _suite_base="${script##*/}" + mkdir -p "${TEMP_DIR_PARALLEL_TEST_SUITE}/${_suite_base%.sh}" 2>/dev/null || true + fi + for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then break @@ -1560,11 +1568,19 @@ function bashunit::runner::parse_result_parallel() { local -a args args=("$@") - local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/$(basename "$test_file" .sh)" - mkdir -p "$test_suite_dir" + # This runs once per test in every parallel worker, so avoid per-test forks: + # derive the suite dir name with parameter expansion (no basename), only + # mkdir when the dir is missing (first test of the file wins the race, + # `-p` makes the losers no-ops), and skip arg sanitizing entirely for the + # common no-provider-args case. + local test_suite_base="${test_file##*/}" + local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/${test_suite_base%.sh}" + [ -d "$test_suite_dir" ] || mkdir -p "$test_suite_dir" - local sanitized_args - sanitized_args=$(echo "${args[*]+"${args[*]}"}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-|-$//') + local sanitized_args="" + if [ -n "${args[*]+"${args[*]}"}" ]; then + sanitized_args=$(echo "${args[*]}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-|-$//') + fi local template if [ -z "$sanitized_args" ]; then template="${fn_name}.XXXXXX" diff --git a/src/str.sh b/src/str.sh index 7b828264..90143eec 100644 --- a/src/str.sh +++ b/src/str.sh @@ -17,6 +17,50 @@ function bashunit::str::strip_ansi_to_slot() { return ;; esac + # Pure-bash path for short strings without backslashes: display lines (e.g. + # the per-test "✓ Passed … 3ms" alignment on systems whose clock is fork-free) + # land here, so a colored line does not cost a sed fork per test. Strip + # CSI sequences segment-wise, then sweep remaining control bytes; the size + # guard avoids bash's quadratic pattern-substitution on large captures and + # `*\\*` still defers to `echo -e` semantics below. + case "$input" in + *\\*) ;; + *) + if [ "${#input}" -le 1024 ]; then + local out="" rest="$input" params + while :; do + case "$rest" in + *$'\x1b'\[*) + out="$out${rest%%$'\x1b'\[*}" + rest="${rest#*$'\x1b'\[}" + params="" + while :; do + case "$rest" in + [0-9\;]*) + params="$params${rest%"${rest#?}"}" + rest="${rest#?}" + ;; + *) break ;; + esac + done + case "$rest" in + # Same finals sed strips; anything else keeps its printable residue + # (the ESC itself falls to the control-byte sweep, exactly like sed). + m* | K*) rest="${rest#?}" ;; + *) out="${out}[${params}" ;; + esac + ;; + *) + out="$out$rest" + break + ;; + esac + done + _BASHUNIT_STR_STRIPPED_OUT=${out//[[:cntrl:]]/} + return + fi + ;; + esac _BASHUNIT_STR_STRIPPED_OUT=$(echo -e "$input" | sed -E 's/\x1B\[[0-9;]*[mK]//g; s/[[:cntrl:]]//g') } diff --git a/tests/acceptance/bashunit_run_forks_test.sh b/tests/acceptance/bashunit_run_forks_test.sh index abb39224..f8703a56 100644 --- a/tests/acceptance/bashunit_run_forks_test.sh +++ b/tests/acceptance/bashunit_run_forks_test.sh @@ -182,3 +182,49 @@ function test_run_removes_its_run_output_dir() { assert_equals 0 "$leftover" } + +# Regression guard for the parallel per-test result path. Publishing each +# test's result file used to fork `basename` (suite dir name), `mkdir -p` +# (suite dir, per test) and an `echo | tr | sed` pipeline (arg sanitizing, even +# with no args) — ~4 forks per test in the mode CI runs everything in. The dir +# name is parameter expansion now, mkdir is guarded by a `[ -d ]` builtin check, +# and arg sanitizing is skipped when there are no provider args. +function test_parallel_result_publishing_does_not_fork_per_test() { + if bashunit::check_os::is_windows; then + bashunit::skip "PATH shims are unreliable under Git Bash" && return + fi + + local dir + dir="$(bashunit::temp_dir)" + local count_file="$dir/count" + local bin + for bin in basename tr sed; do + local real_bin + real_bin="$(command -v "$bin")" + { + echo '#!/usr/bin/env bash' + echo "echo $bin >> \"$count_file\"" + echo "exec \"$real_bin\" \"\$@\"" + } >"$dir/$bin" + chmod +x "$dir/$bin" + done + + local fixture="$dir/parallel_forks_test.sh" + { + echo 'function test_a() { assert_true true; }' + echo 'function test_b() { assert_true true; }' + echo 'function test_c() { assert_true true; }' + echo 'function test_d() { assert_true true; }' + } >"$fixture" + + PATH="$dir:$PATH" ./bashunit --parallel "$fixture" >/dev/null 2>&1 + + # Assert on the shim log's content, not a count: a failure then names the + # offending binary directly in the test output. + local forked="" + if [ -f "$count_file" ]; then + forked="$(sort "$count_file" | uniq -c | tr -d '\n')" + fi + + assert_equals "" "$forked" +} diff --git a/tests/unit/str_test.sh b/tests/unit/str_test.sh index 8f52c52c..3e4547d8 100644 --- a/tests/unit/str_test.sh +++ b/tests/unit/str_test.sh @@ -128,3 +128,31 @@ function test_rpad_width_smaller_than_right_word() { assert_same "... verylongword" "$actual" } + +function test_strip_ansi_to_slot_removes_erase_codes_and_control_chars() { + local input + input="$(printf '\033[2K\033[1;32mok\033[0m\tdone\r')" + + bashunit::str::strip_ansi_to_slot "$input" + + assert_same "okdone" "$_BASHUNIT_STR_STRIPPED_OUT" +} + +function test_strip_ansi_to_slot_long_input_matches_short_path() { + # Inputs beyond the pure-bash size guard take the sed path; both paths must + # produce identical output for the same (repeated) colored payload. + local unit="\033[31mred\033[0m plain " + local long_input="" + local short_expected="" + local n=100 + while [ "$n" -gt 0 ]; do + long_input="${long_input}${unit}" + short_expected="${short_expected}red plain " + n=$((n - 1)) + done + long_input="$(printf '%b' "$long_input")" + + bashunit::str::strip_ansi_to_slot "$long_input" + + assert_same "$short_expected" "$_BASHUNIT_STR_STRIPPED_OUT" +}