From e66ec6bc4cae554bfed49bcf368c7b034d96bac8 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 14 Aug 2026 00:22:32 +0200 Subject: [PATCH] fix(cli): report a bootstrap that never finished loading A bootstrap that fails to load left the run with no tests, no summary and exit 0, so CI passed having executed nothing. A bare 'exit' in the file was worse: entirely silent. [ -f ] && source cannot catch this. source is a POSIX special builtin, so a syntax error in the file, or an exit, terminates the shell at that point -- '|| status=$?' never runs and nothing after the call executes. The --env/--boot flag path already validated its file (#875); the env-var path never did. bash -n would catch it but costs an interpreter fork on every invocation, which the cold-start budget caps at 1, and this repo has a bootstrap -- all ~258 nested acceptance runs would pay it. Leave a marker instead and let the EXIT trap report one that was never cleared: fork-free, and it covers both failure modes. An absent bootstrap stays silent: BASHUNIT_BOOTSTRAP defaults to tests/bootstrap.sh, which most projects do not have. Closes #1179 --- CHANGELOG.md | 1 + src/config/env.sh | 19 +++++++++- src/main/bench.sh | 20 +++++++++-- src/main/test.sh | 20 +++++++++-- tests/acceptance/bashunit_init_test.sh | 48 ++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fc3524..ccf14cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Performance: `--coverage` is roughly 5x faster and `--coverage-report-html` roughly 19x — a run over this repo went from 16.2s to 2.9s, and a 128-file HTML report from 58.7s to 3.1s. The report phase emits each format in one awk invocation per run instead of Bash loops and forks per file and per row, and the capture path writes records straight to disk, normalizes a path with one fork instead of four, and reads each cache once (#1092, #1096, #1098, #1099, #1102, #1104, #1110, #1117) ### Fixed +- A bootstrap file that fails to load now reports it and exits non-zero, instead of leaving the run with no tests, no summary and exit 0. `source` is a POSIX special builtin, so a syntax error or a bare `exit` in the file ends the shell before anything after the call can run — an absent bootstrap is still ignored, since the default path is one most projects do not have (#1179) - A report path that is a directory (`--report-junit build/` with the filename forgotten) fails fast with `is a directory, not a file` instead of passing validation, failing inside the writer with a raw bash message naming a bashunit source file, and exiting 0 with no report written. Affects every report flag and the `bench` equivalents (#1177) - `bashunit init` no longer adds a dead `BASHUNIT_BOOTSTRAP` line to `.env` on every run: it commented out the existing setting and appended a fresh copy even when the value was unchanged, so a third run left three lines, two of them inert. It also now reports what it did to `.env`, the one file it wrote without saying so (#1175) - A coverage run that tracked no executable line at all no longer reports it as `Coverage 0% is below minimum N%`: that 0% is arithmetic rather than a measurement, and the message sent the reader to their tests when the cause is almost always a `--coverage-paths` that matched nothing. The gate still fails, now naming the real problem (#1171) diff --git a/src/config/env.sh b/src/config/env.sh index cd74e1ad..24c9decc 100644 --- a/src/config/env.sh +++ b/src/config/env.sh @@ -948,7 +948,24 @@ function bashunit::env::cleanup_run_output_dir() { # replaces this trap in main.sh and calls the cleanup explicitly instead; child # subshells never inherit EXIT traps, so a parallel worker cannot remove the # directory mid-run. -trap 'bashunit::env::cleanup_run_output_dir' EXIT +# Set while a bootstrap file is being sourced, cleared immediately after. A +# `source` that never returns -- special-builtin semantics on a syntax error or +# a bare `exit` -- leaves it set, which is the only trace such a failure leaves +# (#1179). +_BASHUNIT_LOADING_BOOTSTRAP="" + +function bashunit::env::report_unfinished_bootstrap() { + if [ -n "${_BASHUNIT_LOADING_BOOTSTRAP:-}" ]; then + printf "%sError: the bootstrap file did not load: '%s'.%s\n" \ + "${_BASHUNIT_COLOR_FAILED}" "$_BASHUNIT_LOADING_BOOTSTRAP" \ + "${_BASHUNIT_COLOR_DEFAULT}" >&2 + printf "%s\n" "It ended the shell before any test ran (a syntax error, or an 'exit')." >&2 + _BASHUNIT_LOADING_BOOTSTRAP="" + exit 1 + fi +} + +trap 'bashunit::env::report_unfinished_bootstrap; bashunit::env::cleanup_run_output_dir' EXIT if bashunit::env::is_dev_mode_enabled; then bashunit::internal_log "info" "Dev log enabled" "file:$BASHUNIT_DEV_LOG" diff --git a/src/main/bench.sh b/src/main/bench.sh index 7484abd6..3aeeb624 100644 --- a/src/main/bench.sh +++ b/src/main/bench.sh @@ -134,9 +134,23 @@ function bashunit::main::cmd_bench() { done fi - # Optional bootstrap - # shellcheck disable=SC1090,SC2086 - [ -f "${BASHUNIT_BOOTSTRAP:-}" ] && source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} + # Optional bootstrap. Absent is fine -- BASHUNIT_BOOTSTRAP defaults to + # tests/bootstrap.sh, which most projects do not have. Present but broken is + # not: `[ -f ] && source` swallowed the failure, so a bootstrap with a syntax + # error left the run with no tests, no summary and exit 0, and CI passed + # having executed nothing (#1179). + if [ -f "${BASHUNIT_BOOTSTRAP:-}" ]; then + # `source` is a POSIX special builtin: a syntax error in the file, or a + # bare `exit`, terminates this shell right here -- so nothing after the + # call can report it, and the run ended with no tests, no summary and + # exit 0 (#1179). Pre-validating with `bash -n` would cost an interpreter + # fork on every invocation, which the cold-start budget forbids, so leave + # a marker instead and let the EXIT trap notice it was never cleared. + _BASHUNIT_LOADING_BOOTSTRAP="$BASHUNIT_BOOTSTRAP" + # shellcheck disable=SC1090,SC2086 + source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} + _BASHUNIT_LOADING_BOOTSTRAP="" + fi set +euo pipefail diff --git a/src/main/test.sh b/src/main/test.sh index 4d68e660..810c9fec 100644 --- a/src/main/test.sh +++ b/src/main/test.sh @@ -655,9 +655,23 @@ function bashunit::main::cmd_test() { fi fi - # Optional bootstrap - # shellcheck disable=SC1090,SC2086 - [ -f "${BASHUNIT_BOOTSTRAP:-}" ] && source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} + # Optional bootstrap. Absent is fine -- BASHUNIT_BOOTSTRAP defaults to + # tests/bootstrap.sh, which most projects do not have. Present but broken is + # not: `[ -f ] && source` swallowed the failure, so a bootstrap with a syntax + # error left the run with no tests, no summary and exit 0, and CI passed + # having executed nothing (#1179). + if [ -f "${BASHUNIT_BOOTSTRAP:-}" ]; then + # `source` is a POSIX special builtin: a syntax error in the file, or a + # bare `exit`, terminates this shell right here -- so nothing after the + # call can report it, and the run ended with no tests, no summary and + # exit 0 (#1179). Pre-validating with `bash -n` would cost an interpreter + # fork on every invocation, which the cold-start budget forbids, so leave + # a marker instead and let the EXIT trap notice it was never cleared. + _BASHUNIT_LOADING_BOOTSTRAP="$BASHUNIT_BOOTSTRAP" + # shellcheck disable=SC1090,SC2086 + source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} + _BASHUNIT_LOADING_BOOTSTRAP="" + fi if [ "${BASHUNIT_NO_OUTPUT:-false}" = true ]; then exec >/dev/null 2>&1 diff --git a/tests/acceptance/bashunit_init_test.sh b/tests/acceptance/bashunit_init_test.sh index c91113f1..d6da4f91 100644 --- a/tests/acceptance/bashunit_init_test.sh +++ b/tests/acceptance/bashunit_init_test.sh @@ -97,3 +97,51 @@ function test_bashunit_init_reports_the_env_file_it_writes() { assert_file_contains "$TMP_DIR/init.log" ".env" } + +# A bootstrap that fails to load left the run with nothing: no tests, no +# summary, and exit 0 -- so CI passed having executed nothing. The file is +# optional (BASHUNIT_BOOTSTRAP defaults to tests/bootstrap.sh, which most +# projects do not have), but a file that exists and is broken is a different +# thing from one that is absent (#1179). +function test_a_bootstrap_with_a_syntax_error_fails_the_run() { + pushd "$TMP_DIR" >/dev/null + printf 'function broken( {\n' >boot.sh + printf 'function test_ok() { assert_same 1 1; }\n' >t_test.sh + + local ec=0 + local output + output=$(BASHUNIT_BOOTSTRAP=boot.sh "$BASHUNIT_PATH" --no-parallel t_test.sh 2>&1) || ec=$? + popd >/dev/null + + assert_general_error "" "" "$ec" + assert_contains "bootstrap" "$output" +} + +function test_a_bootstrap_that_exits_non_zero_says_so() { + pushd "$TMP_DIR" >/dev/null + printf 'exit 3\n' >boot.sh + printf 'function test_ok() { assert_same 1 1; }\n' >t_test.sh + + local ec=0 + local output + output=$(BASHUNIT_BOOTSTRAP=boot.sh "$BASHUNIT_PATH" --no-parallel t_test.sh 2>&1) || ec=$? + popd >/dev/null + + assert_not_same 0 "$ec" + assert_contains "bootstrap" "$output" +} + +# An absent bootstrap stays silent: the default path is one most projects do +# not have, and failing on it would break every one of them. +function test_an_absent_bootstrap_is_still_ignored() { + pushd "$TMP_DIR" >/dev/null + printf 'function test_ok() { assert_same 1 1; }\n' >t_test.sh + + local ec=0 + local output + output=$(BASHUNIT_BOOTSTRAP=tests/bootstrap.sh "$BASHUNIT_PATH" --no-parallel t_test.sh 2>&1) || ec=$? + popd >/dev/null + + assert_same 0 "$ec" + assert_not_contains "bootstrap" "$output" +}