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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- `--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: 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
- Faster test runs: each test's subshell result payload is emitted with `printf` (a builtin) instead of a `cat <<EOF` heredoc, removing one `cat` fork per test. No behaviour change
- Faster cold start: the run-output scratch directory and the shared temp directory are created with a single `mkdir -p` call instead of one fork each (2 -> 1 `mkdir` forks per cold start). No behaviour change
Expand Down
21 changes: 18 additions & 3 deletions src/helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ function bashunit::helper::check_duplicate_functions() {

# One awk pass over the file finds each test-function definition and emits only
# the names seen more than once, folding the former grep + awk + sort + uniq
# chain into a single awk + sort (#761).
# chain into a single awk (#761). The END block insertion-sorts the (tiny,
# usually empty) duplicate list itself, so no `sort` fork is needed to keep
# the output deterministic.
local duplicates
duplicates=$(awk '
/^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)[[:space:]]*\{/ {
Expand All @@ -231,11 +233,24 @@ function bashunit::helper::check_duplicate_functions() {
}
}
END {
n = 0
for (name in dup) {
print name
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]
}
}
' "$script" | sort)
' "$script")
if [ -n "$duplicates" ]; then
bashunit::state::set_duplicated_functions_merged "$script" "$duplicates"
return 1
Expand Down
42 changes: 35 additions & 7 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -597,14 +597,42 @@ function bashunit::runner::functions_for_script() {
local script="$1"
local all_fn_names="$2"

# Filter the names down to the ones defined in the script, sort them by line number
shopt -s extdebug
# Resolve "<name> <line> <file>" for the given names, enabling extdebug only
# inside the capture subshell so the caller's setting is untouched.
local declarations
# shellcheck disable=SC2086
declare -F $all_fn_names |
awk -v s="$script" '$3 == s {print $1" " $2}' |
sort -k2 -n |
awk '{print $1}'
shopt -u extdebug
declarations=$(
shopt -s extdebug
declare -F $all_fn_names 2>/dev/null
)

# Keep the functions defined in this script, insertion-sorted by definition
# line. Pure bash: the old `awk | sort | awk` pipeline cost three forks and
# ran twice per file, while a file's function list is small (tens of names).
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 <<EOF
$declarations
EOF

i=0
while [ "$i" -lt "$count" ]; do
echo "${fns[i]}"
i=$((i + 1))
done
}

function bashunit::runner::parse_data_provider_args() {
Expand Down
38 changes: 38 additions & 0 deletions tests/acceptance/bashunit_run_forks_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,41 @@ function test_failure_source_context_does_not_fork_sed_per_line() {

assert_less_than 10 "$sed_forks"
}

# Regression guard: ordering a file's test functions by definition line used to
# pipe `declare -F` through `awk | sort | awk` — and the pipeline ran twice per
# file. The ordering is pure bash now, so a plain run forks `sort` zero times.
# Counted with a PATH shim (a `bash -x` trace would also count re-echoed test
# output, inflating the numbers).
function test_running_a_test_file_does_not_fork_sort() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local real_sort
real_sort="$(command -v sort)"
local dir
dir="$(bashunit::temp_dir)"
local count_file="$dir/count"
{
echo '#!/usr/bin/env bash'
echo "echo x >> \"$count_file\""
echo "exec \"$real_sort\" \"\$@\""
} >"$dir/sort"
chmod +x "$dir/sort"

local fixture="$dir/sort_forks_test.sh"
{
echo 'function test_zz_first() { assert_true true; }'
echo 'function test_aa_second() { assert_true true; }'
} >"$fixture"

PATH="$dir:$PATH" ./bashunit --no-parallel "$fixture" >/dev/null 2>&1

local sort_forks=0
if [ -f "$count_file" ]; then
sort_forks="$(grep -c . "$count_file" || true)"
fi

assert_equals 0 "$sort_forks"
}
51 changes: 51 additions & 0 deletions tests/unit/runner_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,54 @@ function test_supports_reliable_pipefail_matches_bash_version() {
bashunit::runner::_supports_reliable_pipefail || actual_rc=$?
assert_same "$expected_rc" "$actual_rc"
}

function test_functions_for_script_sorts_by_definition_line() {
local fixture
fixture="$(bashunit::temp_dir)/ffs_order_fixture.sh"
{
echo 'function test_zebra() { :; }'
echo 'function test_alpha() { :; }'
echo 'function test_mid() { :; }'
} >"$fixture"
# shellcheck source=/dev/null
source "$fixture"

local actual
actual="$(bashunit::runner::functions_for_script "$fixture" "test_alpha test_mid test_zebra")"

assert_same $'test_zebra\ntest_alpha\ntest_mid' "$actual"
}

function test_functions_for_script_filters_out_functions_from_other_files() {
local fixture
fixture="$(bashunit::temp_dir)/ffs_filter_fixture.sh"
echo 'function test_only_mine() { :; }' >"$fixture"
# shellcheck source=/dev/null
source "$fixture"

# test_functions_for_script_filters_out_functions_from_other_files is defined
# in this test file, not in the fixture, so it must be filtered out.
local actual
actual="$(bashunit::runner::functions_for_script "$fixture" \
"test_only_mine test_functions_for_script_filters_out_functions_from_other_files")"

assert_same "test_only_mine" "$actual"
}

function test_functions_for_script_preserves_caller_extdebug_state() {
local fixture
fixture="$(bashunit::temp_dir)/ffs_extdebug_fixture.sh"
echo 'function test_ffs_extdebug() { :; }' >"$fixture"
# shellcheck source=/dev/null
source "$fixture"

# Toggle inside a subshell so the runner's own shell is never touched.
local state
state=$(
shopt -s extdebug
bashunit::runner::functions_for_script "$fixture" "test_ffs_extdebug" >/dev/null
if shopt -q extdebug; then echo "on"; else echo "off"; fi
)

assert_same "on" "$state"
}
Loading