From 63a5d0fa4c592768dbf282bf60d4825dc359c14a Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 14 Aug 2026 08:48:10 +0200 Subject: [PATCH 1/2] docs(configuration): say what an empty --env entry actually does The guide said an empty entry in the -e/--env/--boot file "does wipe a value", which reads as "reset it to the built-in default". It assigns the empty string instead, and for a boolean that is not the default but a disabled setting: with BASHUNIT_SHOW_HEADER=true in the environment and an empty entry in the --env file, the header is hidden, where a reset to the default (true) would show it. The cause is that `.env` gets a preservation pass in config/env.sh for values the file blanks, while the --env path in main/test.sh is a plain source under allexport with no such pass. Correct the sentence, note the missing preservation pass next to the existing explanation of why the two files differ, and cover the whole six-level ladder with acceptance tests -- none of it was executed anywhere. Closes #1217 --- CHANGELOG.md | 1 + docs/configuration.md | 9 +- .../bashunit_env_precedence_test.sh | 127 ++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 tests/acceptance/bashunit_env_precedence_test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5316f7..b8f1bf59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Changed - `bashunit doc ` says `No assertion matches ''` instead of printing nothing, which was indistinguishable from a broken install. Mirrors the existing `--custom` wording; the exit code stays 0 because `doc` is informational (#1201) +- Docs: an empty entry in the `-e/--env/--boot` file assigns an empty value rather than restoring the built-in default, so blanking a boolean disables it; the guide said it "wipes" the value, which reads as a reset. The rest of the precedence ladder is now covered by tests (#1217) - Docs: the test-function name rule is stated correctly — the prefix is a literal, lowercase `test_`, so the guide's own `testRenderAllTestsPassedWhenNotFailedTests` example and its claim that names are case-insensitive were both wrong, and a function named either way is silently never run (#1215) - Docs: `assert_matches` runs `grep -E` in a subprocess per call — ~2.5ms against ~0.065ms for `assert_same`, about 38x — so hot loops matching a fixed substring should prefer `assert_contains`. The subprocess is required by the Bash 3.0 floor (#1187) - Docs: `assert_match_snapshot` warns that a `@data_provider` test shares one snapshot across all its values — the filename comes from the test function, so the first value creates it and the rest fail against its content. `assert_match_named_snapshot "$1"` gives each value its own (#1185) diff --git a/docs/configuration.md b/docs/configuration.md index 9fe60b51..82d7cdda 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,7 +39,10 @@ to lowest: The `--env` file is sourced during flag parsing, so it overrides `.env`, `.bashunitrc` and the ambient environment, and it loses only to flags written **after** it on the command line: `bashunit --simple --env custom.env` uses the file's value, `bashunit --env custom.env --simple` -uses the flag. Unlike `.env`, an empty entry in that file does wipe a value. +uses the flag. Unlike `.env`, an empty entry in that file is **not** treated as "not +configured here": `BASHUNIT_SHOW_HEADER=` assigns the empty string, which is not the +built-in default — for a boolean setting it simply is not `true`. Delete the entry to fall +back to the default; blanking it disables the setting. An entry left **empty** in `.env` means "not configured here" and does not override the environment, so `BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` keeps @@ -53,7 +56,9 @@ always wins. `--skip-env-file` skips both `.env` and `.bashunitrc`. The two files are read differently, which is why they behave differently: `.env` is **sourced** as a shell script under `allexport`, so it can hold arbitrary shell and an empty entry is unconditional (hence the preservation rule above), while `.bashunitrc` is -parsed as literal `KEY=value` lines and only fills names that are not already set. +parsed as literal `KEY=value` lines and only fills names that are not already set. The +`--env` file is sourced the same way as `.env` but without that preservation pass, which +is why blanking an entry there assigns an empty value instead of being ignored. ## Benchmark reports diff --git a/tests/acceptance/bashunit_env_precedence_test.sh b/tests/acceptance/bashunit_env_precedence_test.sh new file mode 100644 index 00000000..0105b22d --- /dev/null +++ b/tests/acceptance/bashunit_env_precedence_test.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The configuration precedence ladder is six levels deep and every level is a +# claim in docs/configuration.md that nothing executed. The two files are read +# by different code -- `.env` is sourced with a preservation pass for values the +# file blanks, the `--env`/`--boot` file is a plain `source` under allexport -- +# so the levels can drift apart without either side erroring. +# +# The blanking rule is the one that was documented wrong: an empty entry in the +# `--env` file assigns the empty string, which for a boolean is not the default +# but `false` (#1217). +# +# BASHUNIT_SHOW_HEADER (default true) is the probe for the file/environment +# levels; BASHUNIT_SIMPLE_OUTPUT for the two that need a real CLI flag. + +function set_up_before_script() { + BASHUNIT_BIN="$(pwd)/bashunit" +} + +function set_up() { + WORKDIR="$(bashunit::temp_dir)" + printf '%s\n' '#!/usr/bin/env bash' 'function test_ok() { assert_same 1 1; }' \ + >"$WORKDIR/t_test.sh" +} + +# Says "shown" or "hidden" for the run header. Every call is a fresh process, so +# the ambient value is passed in rather than exported here. +function _header() { # $1 = ambient BASHUNIT_SHOW_HEADER ("" for unset), $@ = extra args + local ambient="$1" + shift + local output + if [ -n "$ambient" ]; then + output=$(cd "$WORKDIR" && BASHUNIT_SHOW_HEADER="$ambient" \ + "$BASHUNIT_BIN" --no-parallel "$@" t_test.sh 2>&1 | strip_ansi) || true + else + output=$(cd "$WORKDIR" && "$BASHUNIT_BIN" --no-parallel "$@" t_test.sh 2>&1 | strip_ansi) || true + fi + # The header is colour-wrapped, so this must run on stripped output: an + # unstripped match silently never fires and every case reads as "hidden". + case "$output" in + *"bashunit - "*) echo "shown" ;; + *) echo "hidden" ;; + esac +} + +function _simple() { # $@ = args; says "simple" or "verbose" + local output + output=$(cd "$WORKDIR" && "$BASHUNIT_BIN" --no-parallel "$@" t_test.sh 2>&1) || true + case "$output" in + *"Passed"*) echo "verbose" ;; + *) echo "simple" ;; + esac +} + +function test_the_builtin_default_shows_the_header() { + assert_same "shown" "$(_header "")" +} + +function test_bashunitrc_beats_the_builtin_default() { + printf '%s\n' 'BASHUNIT_SHOW_HEADER=false' >"$WORKDIR/.bashunitrc" + + assert_same "hidden" "$(_header "")" +} + +function test_the_environment_beats_bashunitrc() { + printf '%s\n' 'BASHUNIT_SHOW_HEADER=false' >"$WORKDIR/.bashunitrc" + + assert_same "shown" "$(_header "true")" +} + +function test_an_env_entry_with_a_value_beats_the_environment() { + printf '%s\n' 'BASHUNIT_SHOW_HEADER=false' >"$WORKDIR/.env" + + assert_same "hidden" "$(_header "true")" +} + +# "Not configured here": .env snapshots and restores values it blanks. +function test_an_empty_env_entry_preserves_the_environment() { + printf '%s\n' 'BASHUNIT_SHOW_HEADER=' >"$WORKDIR/.env" + + assert_same "shown" "$(_header "true")" +} + +function test_the_env_flag_file_beats_dot_env() { + printf '%s\n' 'BASHUNIT_SHOW_HEADER=true' >"$WORKDIR/.env" + printf '%s\n' 'BASHUNIT_SHOW_HEADER=false' >"$WORKDIR/custom.env" + + assert_same "hidden" "$(_header "" --env custom.env)" +} + +function test_the_env_flag_file_beats_the_environment() { + printf '%s\n' 'BASHUNIT_SHOW_HEADER=false' >"$WORKDIR/custom.env" + + assert_same "hidden" "$(_header "true" --env custom.env)" +} + +# The documented difference from .env, stated in terms of what is observable: +# the entry overrides, and an empty value is not the default. With the default +# being `true`, a wipe-to-default would show the header; an empty string does +# not equal "true", so it hides it. +function test_an_empty_entry_in_the_env_flag_file_is_not_the_default() { + printf '%s\n' 'BASHUNIT_SHOW_HEADER=' >"$WORKDIR/custom.env" + + assert_same "hidden" "$(_header "true" --env custom.env)" +} + +function test_skip_env_file_skips_both_files() { + printf '%s\n' 'BASHUNIT_SHOW_HEADER=false' >"$WORKDIR/.env" + printf '%s\n' 'BASHUNIT_SHOW_HEADER=false' >"$WORKDIR/.bashunitrc" + + assert_same "shown" "$(_header "" --skip-env-file)" +} + +# The --env file is sourced during flag parsing, so it beats a flag written +# before it and loses to one written after. +function test_a_flag_before_the_env_flag_file_loses_to_it() { + printf '%s\n' 'BASHUNIT_SIMPLE_OUTPUT=false' >"$WORKDIR/custom.env" + + assert_same "verbose" "$(_simple --simple --env custom.env)" +} + +function test_a_flag_after_the_env_flag_file_wins() { + printf '%s\n' 'BASHUNIT_SIMPLE_OUTPUT=false' >"$WORKDIR/custom.env" + + assert_same "simple" "$(_simple --env custom.env --simple)" +} From 6c8e30a8e3c03c93ea25d60ecf6dac9806f3d685 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 14 Aug 2026 08:57:53 +0200 Subject: [PATCH 2/2] test(config): make the precedence test independent of an inherited .env --- tests/acceptance/bashunit_env_precedence_test.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/bashunit_env_precedence_test.sh b/tests/acceptance/bashunit_env_precedence_test.sh index 0105b22d..67471b52 100644 --- a/tests/acceptance/bashunit_env_precedence_test.sh +++ b/tests/acceptance/bashunit_env_precedence_test.sh @@ -26,6 +26,13 @@ function set_up() { # Says "shown" or "hidden" for the run header. Every call is a fresh process, so # the ambient value is passed in rather than exported here. +# +# The "unset" case has to unset it *in the subshell* rather than just not +# setting it: CI runs `cp .env.example .env`, and `.env` is sourced under +# allexport, so the outer run exports BASHUNIT_SHOW_HEADER to every child. An +# inherited value outranks `.bashunitrc` -- correctly, per the ladder -- which +# made the `.bashunitrc` case fail on Linux and the two `--skip-env-file` and +# `--env` cases pass for the wrong reason. function _header() { # $1 = ambient BASHUNIT_SHOW_HEADER ("" for unset), $@ = extra args local ambient="$1" shift @@ -34,7 +41,8 @@ function _header() { # $1 = ambient BASHUNIT_SHOW_HEADER ("" for unset), $@ = ex output=$(cd "$WORKDIR" && BASHUNIT_SHOW_HEADER="$ambient" \ "$BASHUNIT_BIN" --no-parallel "$@" t_test.sh 2>&1 | strip_ansi) || true else - output=$(cd "$WORKDIR" && "$BASHUNIT_BIN" --no-parallel "$@" t_test.sh 2>&1 | strip_ansi) || true + output=$(cd "$WORKDIR" && unset BASHUNIT_SHOW_HEADER && + "$BASHUNIT_BIN" --no-parallel "$@" t_test.sh 2>&1 | strip_ansi) || true fi # The header is colour-wrapped, so this must run on stripped output: an # unstripped match silently never fires and every case reads as "hidden". @@ -46,7 +54,8 @@ function _header() { # $1 = ambient BASHUNIT_SHOW_HEADER ("" for unset), $@ = ex function _simple() { # $@ = args; says "simple" or "verbose" local output - output=$(cd "$WORKDIR" && "$BASHUNIT_BIN" --no-parallel "$@" t_test.sh 2>&1) || true + output=$(cd "$WORKDIR" && unset BASHUNIT_SIMPLE_OUTPUT && + "$BASHUNIT_BIN" --no-parallel "$@" t_test.sh 2>&1) || true case "$output" in *"Passed"*) echo "verbose" ;; *) echo "simple" ;;