diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d844cf3..5fb61f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed - A failing `set_up_before_script`/`set_up` is now reported consistently: previously the same failure could be attributed (plain failing command), silently ignored (failing `cmd && var=x` guard on Bash 3.2 — the hook's real exit status was discarded), or silently counted with an off-by-one total and no message (Bash ≥ 4, where the ERR trap re-fired in the runner's scope). Every test in the affected file is now marked failed with the hook message, totals match the declared count, and the suite continues — a strict test file can no longer abort the whole run mid-suite via its top-level `set -euo pipefail` (#836) - `bashunit::env::is_diff_enabled` and `bashunit::cleanup_testcase_temp_files` no longer break callers under `set -u`/`set -e` (unbound read after `unset BASHUNIT_NO_DIFF`; skip path returned 1) (#836) -- `--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) +- No run-mode flag leaks into nested bashunit runs via the environment anymore: after #834 fixed five flags, the remaining ~40 (`--parallel`, `--simple`, `--strict`, `--filter`, `--retry`, `--seed`, `--watch`, …) are now also this-process-only, so a script under test that itself calls bashunit gets default behavior instead of silently inheriting the parent's output mode, strictness, parallelism, timeouts or report paths. Env-var configuration (`BASHUNIT_*=… bashunit`) is unchanged (#834, #837) - `./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) diff --git a/bashunit b/bashunit index 08763508..0ceb3391 100755 --- a/bashunit +++ b/bashunit @@ -42,10 +42,13 @@ export BASHUNIT_WORKING_DIR for arg in "$@"; do case "$arg" in --skip-env-file) - export BASHUNIT_SKIP_ENV_FILE=true + # this-process-only (see the flag-parse loop in src/main.sh) (#837) + BASHUNIT_SKIP_ENV_FILE=true + export -n BASHUNIT_SKIP_ENV_FILE ;; -l | --login) - export BASHUNIT_LOGIN_SHELL=true + BASHUNIT_LOGIN_SHELL=true + export -n BASHUNIT_LOGIN_SHELL ;; --no-color) # shellcheck disable=SC2034 diff --git a/src/main.sh b/src/main.sh index 35de23cb..cf0c5b24 100644 --- a/src/main.sh +++ b/src/main.sh @@ -27,8 +27,10 @@ function bashunit::main::set_shard_or_exit() { exit 1 fi - export BASHUNIT_SHARD_INDEX="$index" - export BASHUNIT_SHARD_TOTAL="$total" + BASHUNIT_SHARD_INDEX="$index" + export -n BASHUNIT_SHARD_INDEX + BASHUNIT_SHARD_TOTAL="$total" + export -n BASHUNIT_SHARD_TOTAL } ############################# @@ -46,7 +48,18 @@ function bashunit::main::cmd_test() { local assert_fn="" local _bashunit_coverage_opt_set=false - # Parse test-specific options + # Parse test-specific options. + # + # Flag branches assign WITHOUT export and strip the export attribute with + # `export -n`: run-mode flags are this-process-only. Everything that reads + # them (runner, reporters, parallel workers) runs in this shell or its + # subshells, which inherit unexported variables — while exec'd children + # (nested bashunit runs: bashunit's own acceptance suite under + # `build.sh --verify`, or a user's script under test that calls bashunit) + # must NOT inherit the parent's flags (#834, #837). The explicit `export -n` + # also clears an export attribute stamped by an allexport .env load. + # Pair any newly exported-by-necessity flag with a comment naming the exec'd + # consumer, and extend tests/acceptance/fixtures/flag_env_leak/leak_probe.sh. while [ $# -gt 0 ]; do case "$1" in -a | --assert) @@ -74,13 +87,16 @@ function bashunit::main::cmd_test() { shift ;; -s | --simple) - export BASHUNIT_SIMPLE_OUTPUT=true + BASHUNIT_SIMPLE_OUTPUT=true + export -n BASHUNIT_SIMPLE_OUTPUT ;; --detailed) - export BASHUNIT_SIMPLE_OUTPUT=false + BASHUNIT_SIMPLE_OUTPUT=false + export -n BASHUNIT_SIMPLE_OUTPUT ;; --output) - export BASHUNIT_OUTPUT_FORMAT="$2" + BASHUNIT_OUTPUT_FORMAT="$2" + export -n BASHUNIT_OUTPUT_FORMAT shift ;; --debug) @@ -101,35 +117,44 @@ function bashunit::main::cmd_test() { export -n BASHUNIT_STOP_ON_FAILURE ;; -p | --parallel) - export BASHUNIT_PARALLEL_RUN=true + BASHUNIT_PARALLEL_RUN=true + export -n BASHUNIT_PARALLEL_RUN ;; -j | --jobs) - export BASHUNIT_PARALLEL_RUN=true + BASHUNIT_PARALLEL_RUN=true + export -n BASHUNIT_PARALLEL_RUN # "auto" caps at the detected core count; wait_for_job_slot needs an # integer, so resolve it here rather than leaking the string downstream. if [ "$2" = "auto" ]; then - export BASHUNIT_PARALLEL_JOBS="$(bashunit::check_os::nproc)" + BASHUNIT_PARALLEL_JOBS="$(bashunit::check_os::nproc)" + export -n BASHUNIT_PARALLEL_JOBS else - export BASHUNIT_PARALLEL_JOBS="$2" + BASHUNIT_PARALLEL_JOBS="$2" + export -n BASHUNIT_PARALLEL_JOBS fi shift ;; --no-parallel) - export BASHUNIT_PARALLEL_RUN=false + BASHUNIT_PARALLEL_RUN=false + export -n BASHUNIT_PARALLEL_RUN ;; --test-timeout) - export BASHUNIT_TEST_TIMEOUT="$2" + BASHUNIT_TEST_TIMEOUT="$2" + export -n BASHUNIT_TEST_TIMEOUT shift ;; --retry) - export BASHUNIT_RETRY="$2" + BASHUNIT_RETRY="$2" + export -n BASHUNIT_RETRY shift ;; --random-order) - export BASHUNIT_RANDOM_ORDER=true + BASHUNIT_RANDOM_ORDER=true + export -n BASHUNIT_RANDOM_ORDER ;; --seed) - export BASHUNIT_SEED="$2" + BASHUNIT_SEED="$2" + export -n BASHUNIT_SEED shift ;; --shard) @@ -137,17 +162,20 @@ function bashunit::main::cmd_test() { shift ;; --rerun-failed) - export BASHUNIT_RERUN_FAILED=true + BASHUNIT_RERUN_FAILED=true + export -n BASHUNIT_RERUN_FAILED ;; -w | --watch) - export BASHUNIT_WATCH_MODE=true + BASHUNIT_WATCH_MODE=true + export -n BASHUNIT_WATCH_MODE ;; -e | --env | --boot) # Support: --env "bootstrap.sh arg1 arg2" local boot_file="${2%% *}" local boot_args="${2#* }" if [ "$boot_args" != "$2" ]; then - export BASHUNIT_BOOTSTRAP_ARGS="$boot_args" + BASHUNIT_BOOTSTRAP_ARGS="$boot_args" + export -n BASHUNIT_BOOTSTRAP_ARGS fi # Export all variables from the env file so they're available in subshells # (e.g., process substitution used in load_test_files) @@ -169,7 +197,8 @@ function bashunit::main::cmd_test() { shift ;; --log-gha) - export BASHUNIT_LOG_GHA="$2" + BASHUNIT_LOG_GHA="$2" + export -n BASHUNIT_LOG_GHA shift ;; -r | --report-html) @@ -188,50 +217,64 @@ function bashunit::main::cmd_test() { shift ;; --no-output) - export BASHUNIT_NO_OUTPUT=true + BASHUNIT_NO_OUTPUT=true + export -n BASHUNIT_NO_OUTPUT ;; -vvv | --verbose) - export BASHUNIT_VERBOSE=true + BASHUNIT_VERBOSE=true + export -n BASHUNIT_VERBOSE ;; -h | --help) bashunit::console_header::print_test_help exit 0 ;; --show-skipped) - export BASHUNIT_SHOW_SKIPPED=true + BASHUNIT_SHOW_SKIPPED=true + export -n BASHUNIT_SHOW_SKIPPED ;; --show-incomplete) - export BASHUNIT_SHOW_INCOMPLETE=true + BASHUNIT_SHOW_INCOMPLETE=true + export -n BASHUNIT_SHOW_INCOMPLETE ;; --failures-only) - export BASHUNIT_FAILURES_ONLY=true + BASHUNIT_FAILURES_ONLY=true + export -n BASHUNIT_FAILURES_ONLY ;; --fail-on-risky) - export BASHUNIT_FAIL_ON_RISKY=true + BASHUNIT_FAIL_ON_RISKY=true + export -n BASHUNIT_FAIL_ON_RISKY ;; --profile) - export BASHUNIT_PROFILE=true + BASHUNIT_PROFILE=true + export -n BASHUNIT_PROFILE ;; --show-output) - export BASHUNIT_SHOW_OUTPUT_ON_FAILURE=true + BASHUNIT_SHOW_OUTPUT_ON_FAILURE=true + export -n BASHUNIT_SHOW_OUTPUT_ON_FAILURE ;; --no-output-on-failure) - export BASHUNIT_SHOW_OUTPUT_ON_FAILURE=false + BASHUNIT_SHOW_OUTPUT_ON_FAILURE=false + export -n BASHUNIT_SHOW_OUTPUT_ON_FAILURE ;; --no-progress) - export BASHUNIT_NO_PROGRESS=true + BASHUNIT_NO_PROGRESS=true + export -n BASHUNIT_NO_PROGRESS ;; --strict) - export BASHUNIT_STRICT_MODE=true + BASHUNIT_STRICT_MODE=true + export -n BASHUNIT_STRICT_MODE ;; -R | --run-all) - export BASHUNIT_STOP_ON_ASSERTION_FAILURE=false + BASHUNIT_STOP_ON_ASSERTION_FAILURE=false + export -n BASHUNIT_STOP_ON_ASSERTION_FAILURE ;; --skip-env-file) - export BASHUNIT_SKIP_ENV_FILE=true + BASHUNIT_SKIP_ENV_FILE=true + export -n BASHUNIT_SKIP_ENV_FILE ;; -l | --login) - export BASHUNIT_LOGIN_SHELL=true + BASHUNIT_LOGIN_SHELL=true + export -n BASHUNIT_LOGIN_SHELL ;; --no-color) # shellcheck disable=SC2034 @@ -400,7 +443,8 @@ function bashunit::main::cmd_test() { # Disable coverage for assert mode - it's meant for running single assertions, # not tracking code coverage. This also prevents issues when parent bashunit # runs with coverage and calls subprocess bashunit with -a flag. - export BASHUNIT_COVERAGE=false + BASHUNIT_COVERAGE=false + export -n BASHUNIT_COVERAGE bashunit::main::exec_assert "$assert_fn" ${args+"${args[@]}"} else if [ "${BASHUNIT_WATCH_MODE:-false}" = true ]; then @@ -431,7 +475,8 @@ function bashunit::main::cmd_bench() { local -a args=() local args_count=0 - export BASHUNIT_BENCH_MODE=true + BASHUNIT_BENCH_MODE=true + export -n BASHUNIT_BENCH_MODE # Parse bench-specific options while [ $# -gt 0 ]; do @@ -441,17 +486,20 @@ function bashunit::main::cmd_bench() { shift ;; -s | --simple) - export BASHUNIT_SIMPLE_OUTPUT=true + BASHUNIT_SIMPLE_OUTPUT=true + export -n BASHUNIT_SIMPLE_OUTPUT ;; --detailed) - export BASHUNIT_SIMPLE_OUTPUT=false + BASHUNIT_SIMPLE_OUTPUT=false + export -n BASHUNIT_SIMPLE_OUTPUT ;; -e | --env | --boot) # Support: --env "bootstrap.sh arg1 arg2" local boot_file="${2%% *}" local boot_args="${2#* }" if [ "$boot_args" != "$2" ]; then - export BASHUNIT_BOOTSTRAP_ARGS="$boot_args" + BASHUNIT_BOOTSTRAP_ARGS="$boot_args" + export -n BASHUNIT_BOOTSTRAP_ARGS fi # Export all variables from the env file so they're available in subshells # (e.g., process substitution used in load_test_files) @@ -462,13 +510,16 @@ function bashunit::main::cmd_bench() { shift ;; -vvv | --verbose) - export BASHUNIT_VERBOSE=true + BASHUNIT_VERBOSE=true + export -n BASHUNIT_VERBOSE ;; --skip-env-file) - export BASHUNIT_SKIP_ENV_FILE=true + BASHUNIT_SKIP_ENV_FILE=true + export -n BASHUNIT_SKIP_ENV_FILE ;; -l | --login) - export BASHUNIT_LOGIN_SHELL=true + BASHUNIT_LOGIN_SHELL=true + export -n BASHUNIT_LOGIN_SHELL ;; --no-color) # shellcheck disable=SC2034 @@ -819,7 +870,8 @@ function bashunit::main::exec_tests() { # for replay and inherited by parallel test-file subshells. if bashunit::env::is_random_order_enabled; then if [ -z "${BASHUNIT_SEED:-}" ]; then - export BASHUNIT_SEED=$RANDOM + BASHUNIT_SEED=$RANDOM + export -n BASHUNIT_SEED fi if ! bashunit::env::is_tap_output_enabled; then bashunit::console_header::print_random_order_seed "$BASHUNIT_SEED" diff --git a/tests/acceptance/bashunit_flag_env_leak_test.sh b/tests/acceptance/bashunit_flag_env_leak_test.sh index 480984a2..c0607bc8 100644 --- a/tests/acceptance/bashunit_flag_env_leak_test.sh +++ b/tests/acceptance/bashunit_flag_env_leak_test.sh @@ -1,20 +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. +# Regression guard for #834/#837. Run-mode flags 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, silently switched output/strict/parallel modes, and blew the +# per-run fork budget. Every flag the parent sets here must be paired with a +# name in leak_probe.sh's grep list (and vice versa). 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 \ + output=$(./bashunit --no-parallel --simple --strict --skip-env-file \ --stop-on-failure \ + --retry 1 \ + --test-timeout 60 \ + --random-order --seed 7 \ + --no-progress \ + --fail-on-risky \ --log-junit "$dir/log-junit.xml" \ --report-html "$dir/report.html" \ --report-tap "$dir/report.tap" \ diff --git a/tests/acceptance/fixtures/flag_env_leak/leak_probe.sh b/tests/acceptance/fixtures/flag_env_leak/leak_probe.sh index baaadbb3..1e1c6545 100644 --- a/tests/acceptance/fixtures/flag_env_leak/leak_probe.sh +++ b/tests/acceptance/fixtures/flag_env_leak/leak_probe.sh @@ -2,9 +2,16 @@ # 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. +# Every name listed here must be paired with the matching flag in the parent +# invocation: the flag branch's `export -n` is also what clears an export +# attribute stamped by an allexport .env load further up the process tree. function test_run_mode_flags_are_not_in_the_environment() { + local flags='STOP_ON_FAILURE|LOG_JUNIT|REPORT_HTML|REPORT_TAP|REPORT_JSON' + flags="$flags|PARALLEL_RUN|SIMPLE_OUTPUT|STRICT_MODE|RETRY|TEST_TIMEOUT" + flags="$flags|RANDOM_ORDER|SEED|NO_PROGRESS|FAIL_ON_RISKY|SKIP_ENV_FILE" + local leaked - leaked="$(env | grep -E '^BASHUNIT_(STOP_ON_FAILURE|LOG_JUNIT|REPORT_HTML|REPORT_TAP|REPORT_JSON)=' || true)" + leaked="$(env | grep -E "^BASHUNIT_($flags)=" || true)" assert_empty "$leaked" }