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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
## Unreleased

### Fixed
- `--stop-on-failure`, `--log-junit`, `--report-html`, `--report-tap` and `--report-json` no longer leak into nested bashunit runs via the environment: a script under test that itself calls bashunit used to inherit the parent's stop-on-failure mode (aborting before the rerun cache was written) and overwrite the parent's report files (#834)
- `./bashunit bench` works again when running from a repository checkout: the dev entrypoint never sourced `src/benchmark.sh`, so every bench run crashed with `command not found` (the built binary was unaffected) (#834)
- `./build.sh --verify` now exits non-zero when the built binary fails the test suite — previously a red verification run still reported success to CI. The gate exposed that verification had been silently crashing mid-suite since 2025-06: six test files resolved repo paths through the running binary's root dir, which has no `src/` in a build folder; they now resolve paths relative to the test file or repo cwd (#834)
- Snapshot placeholders (`::ignore::`) now work on systems without perl; multi-line placeholders still need perl (#823)
- Runs no longer leak a scratch directory under `$TMPDIR/bashunit/run/` — it is removed on exit, including `--version`/`--help`, subcommands and Ctrl-C (#811)
- `bashunit::helper::get_function_line_number` no longer disables `extdebug` for its caller (#808)
Expand All @@ -16,6 +19,7 @@
- `--jobs auto` / `-j auto` caps parallel concurrency at the CPU core count (portable across Linux/macOS/BSD); the default stays unlimited (#766)

### Changed
- `build.sh` hardened: runs under `set -euo pipefail`, derives the embed list from the entrypoint's `source` order (single source of truth), guards against duplicate embeds and missing doc markers, drops `eval`, and gates every build behind `bash -n` (#834)
- `bashunit doc` no longer forks an `echo | sed` pipe per line of the assertion docs: a single awk pass prints the same bytes in ~50ms instead of ~5s (#832)
- Multi-file runs are no longer quadratic in file count: each file's test functions are unset once the file has been processed, so test subshells stop forking an ever-growing shell. bashunit's own 63-file unit suite: ~64s -> ~22s sequential, ~26s -> ~7s parallel (#829)
- Major performance work with no behaviour change: assertions, per-test execution, per-file discovery, cold start and parallel result publishing are now (near) fork-free, snapshots and `--tag` scans are cached, and quadratic failure rendering is single-pass. Benchmarks on bash 3.2: 100x10 `assert_equals` ~1.50s -> ~0.76s, 500 snapshot assertions ~7.5s -> ~3.0s, 100 tagged tests ~2.92s -> ~0.68s, and bashunit's own acceptance suite ~61s -> ~17s (#761-#764, #772-#775, #798, #801-#807, #809, #810, #813, #817)
Expand Down
1 change: 1 addition & 0 deletions bashunit
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ source "$BASHUNIT_ROOT_DIR/src/doc.sh"
source "$BASHUNIT_ROOT_DIR/src/reports.sh"
source "$BASHUNIT_ROOT_DIR/src/rerun.sh"
source "$BASHUNIT_ROOT_DIR/src/runner.sh"
source "$BASHUNIT_ROOT_DIR/src/benchmark.sh"
source "$BASHUNIT_ROOT_DIR/src/bashunit.sh"
source "$BASHUNIT_ROOT_DIR/src/init.sh"
source "$BASHUNIT_ROOT_DIR/src/learn.sh"
Expand Down
165 changes: 88 additions & 77 deletions build.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail

source src/check_os.sh
bashunit::check_os::init

BASHUNIT_ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export BASHUNIT_ROOT_DIR

# Files already embedded by build::process_file. The source graph is a tree
# today, but one added cross-source would otherwise silently bundle a file
# twice (duplicate function definitions and double top-level execution).
_BUILD_EMBEDDED_FILES=""

function build() {
local out=$1

Expand All @@ -21,59 +28,68 @@ function build::verify() {

echo "Verifying build ⏱️"

BASHUNIT_BUILD_DIR="$out_dir" "$out" tests \
if ! BASHUNIT_BUILD_DIR="$out_dir" "$out" tests \
--simple \
--parallel \
--log-junit "$out_dir/log-junit.xml" \
--report-html "$out_dir/report.html" \
--stop-on-failure

# shellcheck disable=SC2181
if [[ $? -eq 0 ]]; then
echo "✅ Build verified ✅"
--stop-on-failure; then
echo "❌ Build verification failed" >&2
exit 1
fi

echo "✅ Build verified ✅"
}

function build::generate_bin() {
local out=$1
local temp
temp="$(dirname "$out")/temp.sh"
temp="$(dirname "$out")/temp.$$.sh"

echo '#!/usr/bin/env bash' >"$temp"
echo "Generating bashunit in the '$(dirname "$out")' folder..."

local file
for file in $(build::dependencies); do
build::process_file "$file" "$temp"
done

cat bashunit >>"$temp"
grep -v '^source' "$temp" >"$out"
grep -v '^source ' "$temp" >"$out"
rm "$temp"
chmod u+x "$out"

# Embed the assertions.md docs into the binary
build::embed_docs "$out"

build::assert_valid_syntax "$out"
}

# Recursive function to process each file and any files it sources
function build::process_file() {
local file=$1
local temp=$2

case " $_BUILD_EMBEDDED_FILES " in
*" $file "*) return ;;
esac
_BUILD_EMBEDDED_FILES="$_BUILD_EMBEDDED_FILES $file"

{
echo "# $(basename "$file")"
tail -n +2 "$file" >>"$temp"
tail -n +2 "$file"
echo ""
} >>"$temp"

# Search for any 'source' lines in the current file
grep '^source ' "$file" | while read -r line; do
# Extract the path from the 'source' command
# Recurse into any 'source' lines of the current file. Process substitution
# (not a pipe) keeps the loop in this shell so _BUILD_EMBEDDED_FILES persists.
local line
while read -r line; do
local sourced_file
sourced_file=$(echo "$line" | awk '{print $2}' | sed 's/^"//;s/"$//') # Remove any quotes

# Handle cases where the path uses $BASHUNIT_ROOT_DIR or other variables
sourced_file=$(eval echo "$sourced_file")
# Expand the literal $BASHUNIT_ROOT_DIR prefix without eval
sourced_file="${sourced_file/\$BASHUNIT_ROOT_DIR/$BASHUNIT_ROOT_DIR}"

# Handle relative paths if necessary
local _absolute_path_pattern='^/'
Expand All @@ -85,55 +101,38 @@ function build::process_file() {
if [[ -f "$sourced_file" ]]; then
build::process_file "$sourced_file" "$temp"
fi
done
done < <(grep '^source ' "$file" || true)
}

# The embed list is derived from the entrypoint's own source order: a single
# source of truth, so a src file added to the entrypoint can never be missing
# from the distributable (regressions: bench #0.31.0, watch #735).
function build::dependencies() {
deps=(
"src/check_os.sh"
"src/str.sh"
"src/globals.sh"
"src/dependencies.sh"
"src/io.sh"
"src/math.sh"
"src/parallel.sh"
"src/env.sh"
"src/coverage.sh"
"src/clock.sh"
"src/state.sh"
"src/colors.sh"
"src/console_header.sh"
"src/console_results.sh"
"src/helpers.sh"
"src/test_title.sh"
"src/upgrade.sh"
"src/watch.sh"
"src/assertions.sh"
"src/reports.sh"
"src/rerun.sh"
"src/runner.sh"
"src/benchmark.sh"
"src/init.sh"
"src/learn.sh"
"src/doc.sh"
"src/bashunit.sh"
"src/main.sh"
)

echo "${deps[@]}"
grep '^source ' bashunit \
| sed -e 's|^source "\$BASHUNIT_ROOT_DIR/||' -e 's|"$||' \
| grep -v '^src/dev/'
}

function build::embed_docs() {
local file=$1
local docs_file="docs/assertions.md"
local temp_file="${file}.tmp"

local start_line
start_line=$(grep -n "# __BASHUNIT_EMBEDDED_DOCS_START__" "$file" | cut -d: -f1 | head -n 1) || true
if [[ -z "$start_line" ]]; then
echo "❌ Embed marker __BASHUNIT_EMBEDDED_DOCS_START__ not found in $file" >&2
exit 1
fi
if ! grep -q "# __BASHUNIT_EMBEDDED_DOCS_END__" "$file"; then
echo "❌ Embed marker __BASHUNIT_EMBEDDED_DOCS_END__ not found in $file" >&2
exit 1
fi

# Build the replacement content
{
# Print everything before the start marker (excluding the marker line)
local line_num
line_num=$(grep -n "# __BASHUNIT_EMBEDDED_DOCS_START__" "$file" | cut -d: -f1)
head -n "$((line_num - 1))" "$file"
head -n "$((start_line - 1))" "$file"

# Print the heredoc with embedded docs
echo " cat <<'__BASHUNIT_DOCS_EOF__'"
Expand All @@ -148,10 +147,19 @@ function build::embed_docs() {
chmod u+x "$file"
}

function build::assert_valid_syntax() {
local file=$1

if ! bash -n "$file"; then
echo "❌ Generated artifact failed bash -n syntax check: $file" >&2
exit 1
fi
}

function build::generate_checksum() {
local out=$1

if [[ "$_OS" == "Windows" ]]; then
if [[ "$_BASHUNIT_OS" == "Windows" ]]; then
return
fi

Expand All @@ -170,34 +178,37 @@ function build::generate_checksum() {
######### MAIN #########
########################

DIR="bin"
SHOULD_VERIFY_BUILD=false
SHOULD_CLEANUP=false

for arg in "$@"; do
case $arg in
-v | --verify)
SHOULD_VERIFY_BUILD=true
;;
-c | --cleanup)
SHOULD_CLEANUP=true
;;
*)
DIR=$arg
;;
esac
done
# Skip when sourced (tests source this file to exercise the functions above)
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
DIR="bin"
SHOULD_VERIFY_BUILD=false
SHOULD_CLEANUP=false

for arg in "$@"; do
case $arg in
-v | --verify)
SHOULD_VERIFY_BUILD=true
;;
-c | --cleanup)
SHOULD_CLEANUP=true
;;
*)
DIR=$arg
;;
esac
done

mkdir -p "$DIR"
OUT="$DIR/bashunit"
mkdir -p "$DIR"
OUT="$DIR/bashunit"

build "$OUT"
build "$OUT"

if [[ $SHOULD_VERIFY_BUILD == true ]]; then
build::verify "$OUT"
fi
if [[ $SHOULD_VERIFY_BUILD == true ]]; then
build::verify "$OUT"
fi

if [[ $SHOULD_CLEANUP == true ]]; then
echo "🧹 Cleaning up build directory: $DIR"
rm -rf "$DIR"
if [[ $SHOULD_CLEANUP == true ]]; then
echo "🧹 Cleaning up build directory: $DIR"
rm -rf "$DIR"
fi
fi
26 changes: 21 additions & 5 deletions src/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,13 @@ function bashunit::main::cmd_test() {
set -x
;;
-S | --stop-on-failure)
export BASHUNIT_STOP_ON_FAILURE=true
# This-process-only: parallel stop uses a flag file and sync stop uses
# exit codes, so no child process needs it — exported (including via an
# allexport .env load, hence export -n) it leaked into nested bashunit
# runs, aborting them before rerun::persist could write
# .bashunit/last-failed (broke verify's acceptance tests, #834).
BASHUNIT_STOP_ON_FAILURE=true
export -n BASHUNIT_STOP_ON_FAILURE
;;
-p | --parallel)
export BASHUNIT_PARALLEL_RUN=true
Expand Down Expand Up @@ -151,24 +157,34 @@ function bashunit::main::cmd_test() {
set +o allexport
shift
;;
# Report flags are this-process-only: reports are generated by the main
# shell after aggregation, no child process reads these. `export -n` also
# strips an export attribute inherited from an allexport .env load —
# otherwise nested bashunit runs (bashunit's own acceptance tests, or a
# user's scripts under test that call bashunit) silently write their own
# reports over the parent's files and blow the per-run fork budget (#834).
--log-junit | --report-junit)
export BASHUNIT_LOG_JUNIT="$2"
BASHUNIT_LOG_JUNIT="$2"
export -n BASHUNIT_LOG_JUNIT
shift
;;
--log-gha)
export BASHUNIT_LOG_GHA="$2"
shift
;;
-r | --report-html)
export BASHUNIT_REPORT_HTML="$2"
BASHUNIT_REPORT_HTML="$2"
export -n BASHUNIT_REPORT_HTML
shift
;;
--report-tap)
export BASHUNIT_REPORT_TAP="$2"
BASHUNIT_REPORT_TAP="$2"
export -n BASHUNIT_REPORT_TAP
shift
;;
--report-json)
export BASHUNIT_REPORT_JSON="$2"
BASHUNIT_REPORT_JSON="$2"
export -n BASHUNIT_REPORT_JSON
shift
;;
--no-output)
Expand Down
20 changes: 20 additions & 0 deletions tests/acceptance/bashunit_bench_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for #834. The dev entrypoint never sourced src/benchmark.sh
# (only the hand-maintained build list bundled it), so `./bashunit bench` worked
# in the built binary but crashed with `command not found` in dev mode — and no
# *_test.sh exercised the bench CLI path.
function test_bench_command_runs_in_dev_mode() {
local fixture_dir
fixture_dir=$(bashunit::temp_dir)
printf '#!/usr/bin/env bash\nfunction bench_sample() { :; }\n' >"$fixture_dir/sample_bench.sh"

local output
local exit_code=0
output=$(./bashunit bench "$fixture_dir/sample_bench.sh" 2>&1) || exit_code=$?

assert_equals 0 "$exit_code"
assert_not_contains "command not found" "$output"
assert_contains "bench_sample" "$output"
}
26 changes: 26 additions & 0 deletions tests/acceptance/bashunit_flag_env_leak_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for #834. Run-mode flags (--stop-on-failure and the report
# writers) used to be exported, so nested bashunit runs — bashunit's own
# acceptance tests under `build.sh --verify`, or a user's scripts under test
# that call bashunit — inherited them: nested runs aborted before persisting
# the rerun cache, wrote their own reports over the parent's files, and blew
# the per-run fork budget.
function test_run_mode_flags_do_not_leak_into_nested_runs() {
local dir
dir=$(bashunit::temp_dir)

local output
local exit_code=0
output=$(./bashunit --no-parallel --skip-env-file \
--stop-on-failure \
--log-junit "$dir/log-junit.xml" \
--report-html "$dir/report.html" \
--report-tap "$dir/report.tap" \
--report-json "$dir/report.json" \
tests/acceptance/fixtures/flag_env_leak/leak_probe.sh 2>&1) || exit_code=$?

assert_equals 0 "$exit_code"
assert_contains "1 passed" "$output"
}
10 changes: 10 additions & 0 deletions tests/acceptance/fixtures/flag_env_leak/leak_probe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash

# Probe used by bashunit_flag_env_leak_test.sh: fails if any run-mode flag of
# the parent bashunit process leaked into this (nested) run's environment.
function test_run_mode_flags_are_not_in_the_environment() {
local leaked
leaked="$(env | grep -E '^BASHUNIT_(STOP_ON_FAILURE|LOG_JUNIT|REPORT_HTML|REPORT_TAP|REPORT_JSON)=' || true)"

assert_empty "$leaked"
}
Loading
Loading