Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/math.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 20 additions & 4 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
44 changes: 44 additions & 0 deletions src/str.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down
46 changes: 46 additions & 0 deletions tests/acceptance/bashunit_run_forks_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
28 changes: 28 additions & 0 deletions tests/unit/str_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading