diff --git a/.claude/rules/architecture-map.md b/.claude/rules/architecture-map.md index ca065d6b..3ce7a275 100644 --- a/.claude/rules/architecture-map.md +++ b/.claude/rules/architecture-map.md @@ -33,7 +33,7 @@ bashunit (entry) sources all src/*.sh; version gate; early flag scan │ parallel: + write .result via mktemp └─ console_results::print_successful_test / _failed_ / … └─ str::rpad + strip_ansi align per-test time (pure bash) - └─ [--parallel] wait; parallel::aggregate_test_results over *.result files + └─ [--parallel] wait; state::aggregate_parallel_results over *.result files └─ console_results::render_result totals; deferred failure/skip blocks └─ rerun::persist; env::cleanup_run_output_dir; exit code ``` diff --git a/.env.example b/.env.example index 0c2adbf4..7958e79c 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,13 @@ # bashunit Configuration # Copy this file to .env and customize as needed # All values shown are defaults (leave empty to use default) +# +# Do NOT add a variable here just to document it. .env is loaded with +# `set -o allexport; source .env`, so every line is an unconditional assignment: +# listing a name makes an empty value OVERRIDE one the caller exported or set on +# the command line (`BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` stops working). +# Settings that must stay overridable are documented in docs/configuration.md +# instead. `.bashunitrc` does not have this problem — it applies only when unset. ################################################################################ #─────────────────────────────────────────────────────────────────────────────── @@ -19,7 +26,7 @@ BASHUNIT_HEADER_ASCII_ART= # Default: false BASHUNIT_SIMPLE_OUTPUT= # Default: false (use dots instead of test names) BASHUNIT_VERBOSE= # Default: false (show environment variables) BASHUNIT_NO_OUTPUT= # Default: false (suppress all output) -BASHUNIT_SHOW_EXECUTION_TIME= # Default: true +BASHUNIT_SHOW_EXECUTION_TIME= # Default: auto (true|false|auto; auto skips per-test times when the clock forks) BASHUNIT_SHOW_SKIPPED= # Default: false (show skipped test details) BASHUNIT_SHOW_INCOMPLETE= # Default: false (show incomplete test details) BASHUNIT_FAILURES_ONLY= # Default: false (only show failures) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd411ecf..5c97e808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ ### Added - Per-line execution hit counts in the text report: `BASHUNIT_COVERAGE_SHOW_LINE_HITS=true` prints a `Line Hits` block listing each covered line as `:` per file. The LCOV report already carried the same counts in its `DA:,` records; those are now pinned by tests (#856) +### Changed +- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded: Alpine has been a supported parallel platform since the race conditions were fixed, the message was simply never updated + +### Fixed +- `.env.example` documented `BASHUNIT_SHOW_EXECUTION_TIME` as defaulting to `true`; the actual default has been `auto` since #765 +- bashunit now aborts with an actionable error when its scratch directories under `TMPDIR` cannot be created. Previously the run continued with every failure/skip collector writing nowhere, so a failing suite still exited non-zero but lost its assertion detail and leaked raw `src/runner.sh: line N: ...: Not a directory` errors instead of reporting the cause +- A test file whose `set_up_before_script` changes directory no longer silently drops the remaining files from the run when the original working directory has become unreachable; the run aborts with a clear error instead +- `release.sh` reports a failed rollback as failed instead of always printing "Rollback complete", and aborts when neither the tar nor the `cp` sandbox copy produced a usable project copy +- `assert_arrays_equal` no longer reports the internal label `Bashunit::assert::label` when it fails outside a test function (for example from a `set_up` hook); it now reports `Assert arrays equal`, matching every other assertion +- The exit-code assertions (`assert_exit_code`, `assert_successful_code`, `assert_unsuccessful_code`, `assert_general_error`, `assert_command_not_found`) counted the assertion as **passed** when given a non-integer exit code: `[ x -ne y ]` exits 2 rather than 1 on an unparseable operand, and that was read as "equal". They now fail closed +- A test path containing both a glob and a space (`./bashunit "my tests/*"`) is no longer word-split into bogus search roots, which silently discovered zero tests; the path is also no longer passed through `eval` +- The variadic assertions (`assert_contains` and friends) called with the actual value omitted now report a clean failed assertion on Bash 3.2 instead of aborting the run with `unbound variable` under `--strict` +- An unreadable or truncated parallel `.result` file is now counted as a failed test instead of aborting aggregation with an arithmetic syntax error + +### Removed +- Dead internal code with no remaining callers: the pre-cache fallback branch of the runner's `call_test_functions` (unreachable since the per-file function list became mandatory), `bashunit::state::calculate_total_assertions` (superseded by `bashunit::runner::compute_total_assertions`), `bashunit::coverage::get_line_hits` (superseded by `bashunit::coverage::get_all_line_hits`), `bashunit::helper::trim` and `bashunit::dependencies::has_adjtimex`. All are internal (sub-namespaced) helpers, not part of the documented public API + ## [0.43.0](https://github.com/TypedDevs/bashunit/compare/0.42.0...0.43.0) - 2026-07-24 ### Added diff --git a/release.sh b/release.sh index 342b727c..707d0eaa 100755 --- a/release.sh +++ b/release.sh @@ -326,8 +326,15 @@ function release::rollback::restore_files() { function release::rollback::auto() { release::log_error "Release failed. Initiating rollback..." - release::rollback::restore_files || true - release::log_info "Rollback complete. Files restored to pre-release state." + # `if` (not `|| true`) so a failed restore is reported as such: announcing + # "Rollback complete" over a restore that never happened sends the operator + # away from a half-released working tree. + if release::rollback::restore_files; then + release::log_info "Rollback complete. Files restored to pre-release state." + else + release::log_error "Rollback FAILED: files were not restored from backup." + release::log_error "The working tree may be left in a half-released state." + fi release::log_info "Manual rollback command if needed: ./release.sh --rollback" } @@ -400,6 +407,20 @@ function release::sandbox::create() { "$SANDBOX_DIR/node_modules" "$SANDBOX_DIR/.tasks" "$SANDBOX_DIR/tmp" release::log_verbose "Copied project files to sandbox (cp)" fi + + # Neither copy method reports a conclusive exit code (the tar pipe silences + # its own stderr, and `cp -r` may trip over transient files), so assert the + # postcondition explicitly. A sandbox rehearsal on an empty directory would + # otherwise "pass" every step it never actually ran. + if [ ! -f "$SANDBOX_DIR/bashunit" ]; then + release::log_error "Failed to copy the project into the sandbox: $SANDBOX_DIR" + release::log_error "Neither the tar pipe nor the cp fallback produced a usable copy." + # Default the constant: when release.sh is sourced from a function (the unit + # tests do), its top-level `declare -r` is function-local and long gone by + # now — a bare `exit $EXIT_EXECUTION_ERROR` would then exit 0 and turn this + # fatal error into a silent pass. + exit "${EXIT_EXECUTION_ERROR:-2}" + fi } function release::sandbox::setup_git() { diff --git a/src/assert.sh b/src/assert.sh index a893a28d..16a2352a 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -15,24 +15,57 @@ _BASHUNIT_ASSERT_LABEL_OUT="" # Resolve assertion label into the slot _BASHUNIT_ASSERT_LABEL_OUT with no fork: # use custom label if provided, otherwise derive from the test function name. -# Must be called at the same stack depth as the echoing wrapper so the test-frame -# fallback keeps resolving against the caller of the assertion. +# +# $2 is the frame to fall back to when no test_* function is on the stack. It +# defaults to 2 (the caller of this function), so any wrapper that adds a frame +# between the assertion and this call must pass its own depth -- otherwise the +# fallback reports the wrapper's name instead of the assertion's. +# Arguments: $1 - custom label (optional), $2 - fallback depth (default 2) function bashunit::assert::label_to_slot() { local custom_label="${1:-}" + local fallback_depth="${2:-2}" if [ -n "$custom_label" ]; then _BASHUNIT_ASSERT_LABEL_OUT=$custom_label return fi - bashunit::helper::find_test_function_name_to_slot + bashunit::helper::find_test_function_name_to_slot "$fallback_depth" bashunit::helper::normalize_test_function_name_to_slot "$_BASHUNIT_HELPER_TESTFN_OUT" _BASHUNIT_ASSERT_LABEL_OUT=$_BASHUNIT_HELPER_NORMALIZED_OUT } +## +# Reports an assertion failure: resolves the label, marks the assertion failed +# and prints the standard "Expected / " block. Collapses the +# label_to_slot + mark_failed + print_failed_test sequence every assertion +# repeats. +# +# The fallback depth is 3, not label_to_slot's default 2, to account for this +# extra stack frame: when no test_* frame is on the stack the label must still +# resolve to the *assertion* that called this helper. Guarded by the +# "labelled with its own name" tests in +# tests/acceptance/bashunit_hook_failure_test.sh. +# +# Arguments: $1 - label override (empty to derive), $2 - expected, $3 - failure +# condition message, $4 - actual, $5 - extra key (optional), +# $6 - extra value (optional) +## +function bashunit::assert::fail_with() { + bashunit::assert::label_to_slot "${1:-}" 3 + bashunit::assert::mark_failed + bashunit::console_results::print_failed_test \ + "$_BASHUNIT_ASSERT_LABEL_OUT" "${2-}" "${3-}" "${4-}" "${5-}" "${6-}" +} + _BASHUNIT_ASSERT_JOINED_OUT="" # Join positional args into _BASHUNIT_ASSERT_JOINED_OUT with no fork. # Output matches $(printf '%s\n' "$@") exactly: newline-joined, trailing # newlines stripped (as command substitution strips them). +# Callers pass their variadic "actual" as "${arr[@]+"${arr[@]}"}": an assertion +# invoked with no actual value leaves that array empty, and a bare +# "${arr[@]}" on an empty array is an unbound-variable error under `set -u` +# (i.e. --strict) on Bash < 4.4. The guard makes an empty actual join to "" +# on every supported Bash instead of aborting the test only on Bash 3.x. function bashunit::assert::join_to_slot() { local IFS=$'\n' local joined="$*" @@ -171,10 +204,7 @@ function assert_same() { local label_override="${3:-}" if [ "$expected" != "$actual" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${actual}" + bashunit::assert::fail_with "${label_override:-}" "${expected}" "but got " "${actual}" return fi @@ -194,10 +224,7 @@ function assert_equals() { local expected_cleaned=$_BASHUNIT_STR_STRIPPED_OUT if [ "$expected_cleaned" != "$actual_cleaned" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected_cleaned}" "but got " "${actual_cleaned}" + bashunit::assert::fail_with "${label_override:-}" "${expected_cleaned}" "but got " "${actual_cleaned}" return fi @@ -217,10 +244,7 @@ function assert_not_equals() { local expected_cleaned=$_BASHUNIT_STR_STRIPPED_OUT if [ "$expected_cleaned" = "$actual_cleaned" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected_cleaned}" "to not be" "${actual_cleaned}" + bashunit::assert::fail_with "${label_override:-}" "${expected_cleaned}" "to not be" "${actual_cleaned}" return fi @@ -234,10 +258,7 @@ function assert_empty() { local label_override="${2:-}" if [ "$expected" != "" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "to be empty" "but got " "${expected}" + bashunit::assert::fail_with "${label_override:-}" "to be empty" "but got " "${expected}" return fi @@ -251,10 +272,7 @@ function assert_not_empty() { local label_override="${2:-}" if [ "$expected" = "" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "to not be empty" "but got " "${expected}" + bashunit::assert::fail_with "${label_override:-}" "to not be empty" "but got " "${expected}" return fi @@ -269,10 +287,7 @@ function assert_not_same() { local label_override="${3:-}" if [ "$expected" = "$actual" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to not be" "${actual}" + bashunit::assert::fail_with "${label_override:-}" "${expected}" "to not be" "${actual}" return fi @@ -287,16 +302,13 @@ function assert_contains() { local -a actual_arr actual_arr=("${@:2}") local label_override="" - bashunit::assert::join_to_slot "${actual_arr[@]}" + bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected"*) ;; *) - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to contain" "${expected}" return ;; esac @@ -321,10 +333,7 @@ function assert_contains_ignore_case() { case "$actual_lower" in *"$expected_lower"*) ;; *) - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to contain" "${expected}" return ;; esac @@ -340,15 +349,12 @@ function assert_not_contains() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - bashunit::assert::join_to_slot "${actual_arr[@]}" + bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected"*) - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to not contain" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to not contain" "${expected}" return ;; esac @@ -363,7 +369,7 @@ function assert_matches() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - bashunit::assert::join_to_slot "${actual_arr[@]}" + bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT if [ "$(printf '%s' "$actual" | "$GREP" -cE "$expected" || true)" -eq 0 ]; then @@ -389,16 +395,13 @@ function assert_not_matches() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - bashunit::assert::join_to_slot "${actual_arr[@]}" + bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT # Check both line-by-line and with newlines collapsed for cross-line patterns if [ "$(printf '%s' "$actual" | "$GREP" -cE "$expected" || true)" -gt 0 ] || [ "$(printf '%s' "$actual" | tr '\n' ' ' | "$GREP" -cE "$expected" || true)" -gt 0 ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to not match" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to not match" "${expected}" return fi @@ -557,10 +560,7 @@ function assert_exec() { fi if [ "$failed" -eq 1 ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "$label" "$expected_desc" "but got " "$actual_desc" + bashunit::assert::fail_with "${label_override:-}" "$expected_desc" "but got " "$actual_desc" return fi @@ -574,11 +574,12 @@ function assert_exit_code() { local expected_exit_code="$1" - if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual_exit_code}" "to be" "${expected_exit_code}" + # State the PASS condition and negate it. `[ -eq ]` exits 2 (not 1) on a + # non-integer operand, so the old `[ -ne ]` form read that error as "equal" + # and counted the assertion as passed. Negating makes unparseable input + # fail closed, matching the `! [ -lt ]` form of the comparison assertions. + if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then + bashunit::assert::fail_with "${label_override:-}" "${actual_exit_code}" "to be" "${expected_exit_code}" return fi @@ -592,12 +593,10 @@ function assert_successful_code() { local expected_exit_code=0 - if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" + # Negated pass condition: see assert_exit_code. + if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then + bashunit::assert::fail_with "${label_override:-}" \ + "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi @@ -609,11 +608,9 @@ function assert_unsuccessful_code() { local label_override="" bashunit::assert::should_skip && return 0 - if [ "$actual_exit_code" -eq 0 ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual_exit_code}" "to be non-zero" "but was 0" + # Negated pass condition: see assert_exit_code. + if ! [ "$actual_exit_code" -ne 0 ]; then + bashunit::assert::fail_with "${label_override:-}" "${actual_exit_code}" "to be non-zero" "but was 0" return fi @@ -627,12 +624,10 @@ function assert_general_error() { local expected_exit_code=1 - if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" + # Negated pass condition: see assert_exit_code. + if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then + bashunit::assert::fail_with "${label_override:-}" \ + "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi @@ -646,12 +641,10 @@ function assert_command_not_found() { local expected_exit_code=127 - if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" + # Negated pass condition: see assert_exit_code. + if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then + bashunit::assert::fail_with "${label_override:-}" \ + "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi @@ -666,16 +659,13 @@ function assert_string_starts_with() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - bashunit::assert::join_to_slot "${actual_arr[@]}" + bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in "$expected"*) ;; *) - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to start with" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to start with" "${expected}" return ;; esac @@ -692,10 +682,7 @@ function assert_string_not_starts_with() { case "$actual" in "$expected"*) - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to not start with" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to not start with" "${expected}" return ;; esac @@ -711,16 +698,13 @@ function assert_string_ends_with() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - bashunit::assert::join_to_slot "${actual_arr[@]}" + bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected") ;; *) - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to end with" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to end with" "${expected}" return ;; esac @@ -736,15 +720,12 @@ function assert_string_not_ends_with() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - bashunit::assert::join_to_slot "${actual_arr[@]}" + bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected") - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to not end with" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to not end with" "${expected}" return ;; esac @@ -760,10 +741,7 @@ function assert_less_than() { local label_override="${3:-}" if ! [ "$actual" -lt "$expected" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to be less than" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to be less than" "${expected}" return fi @@ -778,10 +756,7 @@ function assert_less_or_equal_than() { local label_override="${3:-}" if ! [ "$actual" -le "$expected" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to be less or equal than" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to be less or equal than" "${expected}" return fi @@ -796,10 +771,7 @@ function assert_greater_than() { local label_override="${3:-}" if ! [ "$actual" -gt "$expected" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to be greater than" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to be greater than" "${expected}" return fi @@ -814,10 +786,7 @@ function assert_greater_or_equal_than() { local label_override="${3:-}" if ! [ "$actual" -ge "$expected" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to be greater or equal than" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to be greater or equal than" "${expected}" return fi @@ -855,10 +824,7 @@ function assert_within_delta() { if ! bashunit::assert::_is_numeric "$expected" || ! bashunit::assert::_is_numeric "$actual" || ! bashunit::assert::_is_numeric "$delta"; then - bashunit::assert::label_to_slot - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "${_BASHUNIT_ASSERT_LABEL_OUT}" "${expected} ${actual} ${delta}" \ + bashunit::assert::fail_with "" "${expected} ${actual} ${delta}" \ "to all be numeric" "but got a non-numeric value" return fi @@ -870,10 +836,7 @@ function assert_within_delta() { esac if [ "$(bashunit::math::calculate "$diff <= $delta")" != "1" ]; then - bashunit::assert::label_to_slot - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "${_BASHUNIT_ASSERT_LABEL_OUT}" "${actual}" "to be within ${delta} of" "${expected}" + bashunit::assert::fail_with "" "${actual}" "to be within ${delta} of" "${expected}" return fi @@ -910,11 +873,7 @@ function assert_line_count() { fi if [ "$expected" != "$actual" ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${input_str}" \ + bashunit::assert::fail_with "${label_override:-}" "${input_str}" \ "to contain number of lines equal to" "${expected}" \ "but found" "${actual}" return @@ -976,10 +935,7 @@ function assert_string_matches_format() { regex="$(bashunit::format_to_regex "$format")" if [ "$(printf '%s' "$actual" | "$GREP" -cE "$regex" || true)" -eq 0 ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to match format" "${format}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to match format" "${format}" return fi @@ -997,10 +953,7 @@ function assert_string_not_matches_format() { regex="$(bashunit::format_to_regex "$format")" if [ "$(printf '%s' "$actual" | "$GREP" -cE "$regex" || true)" -gt 0 ]; then - bashunit::assert::label_to_slot "${label_override:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to not match format" "${format}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to not match format" "${format}" return fi diff --git a/src/assert_arrays.sh b/src/assert_arrays.sh index 705cd7da..c995684b 100644 --- a/src/assert_arrays.sh +++ b/src/assert_arrays.sh @@ -3,9 +3,6 @@ function assert_arrays_equal() { bashunit::assert::should_skip && return 0 - local label - label="$(bashunit::assert::label)" - local -a expected_values=() local -a actual_values=() local found_separator=false @@ -25,15 +22,12 @@ function assert_arrays_equal() { done if [ "$found_separator" = false ]; then - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "$label" "--" "but got " "missing array separator" + bashunit::assert::fail_with "" "--" "but got " "missing array separator" return fi if [ "${#expected_values[@]}" -ne "${#actual_values[@]}" ]; then - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "$label" "${expected_values[*]}" "but got " "${actual_values[*]}" \ + bashunit::assert::fail_with "" "${expected_values[*]}" "but got " "${actual_values[*]}" \ "Expected length" "${#expected_values[@]}, actual length ${#actual_values[@]}" return fi @@ -41,9 +35,7 @@ function assert_arrays_equal() { local index for ((index = 0; index < ${#expected_values[@]}; index++)); do if [ "${expected_values[$index]}" != "${actual_values[$index]}" ]; then - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "$label" "${expected_values[*]}" "but got " "${actual_values[*]}" \ + bashunit::assert::fail_with "" "${expected_values[*]}" "but got " "${actual_values[*]}" \ "Different index" "$index" return fi @@ -56,19 +48,15 @@ function assert_array_contains() { bashunit::assert::should_skip && return 0 local expected="$1" - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT shift local -a actual actual=("$@") case "${actual[*]:-}" in - *"$expected"*) - ;; + *"$expected"*) ;; *) - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to contain" "${expected}" + bashunit::assert::fail_with "" "${actual[*]}" "to contain" "${expected}" return ;; esac @@ -80,8 +68,6 @@ function assert_array_length() { bashunit::assert::should_skip && return 0 local expected="$1" - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT shift # Use $# / $* rather than building an array: on Bash 3.0 under `set -u`, @@ -89,9 +75,7 @@ function assert_array_length() { local actual_length="$#" if [ "$expected" != "$actual_length" ]; then - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "${label}" "$*" "to have length ${expected}" "but got ${actual_length}" + bashunit::assert::fail_with "" "$*" "to have length ${expected}" "but got ${actual_length}" return fi @@ -102,16 +86,13 @@ function assert_array_not_contains() { bashunit::assert::should_skip && return 0 local expected="$1" - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT shift local -a actual actual=("$@") case "${actual[*]:-}" in *"$expected"*) - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to not contain" "${expected}" + bashunit::assert::fail_with "" "${actual[*]}" "to not contain" "${expected}" return ;; esac diff --git a/src/assert_dates.sh b/src/assert_dates.sh index 48a609df..d0d9793d 100644 --- a/src/assert_dates.sh +++ b/src/assert_dates.sh @@ -105,10 +105,7 @@ function assert_date_equals() { actual="$(bashunit::date::to_epoch "$2")" if [ "$actual" -ne "$expected" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to be equal to" "${expected}" + bashunit::assert::fail_with "" "${actual}" "to be equal to" "${expected}" return fi @@ -124,10 +121,7 @@ function assert_date_before() { actual="$(bashunit::date::to_epoch "$2")" if [ "$actual" -ge "$expected" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to be before" "${expected}" + bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}" return fi @@ -143,10 +137,7 @@ function assert_date_after() { actual="$(bashunit::date::to_epoch "$2")" if [ "$actual" -le "$expected" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to be after" "${expected}" + bashunit::assert::fail_with "" "${actual}" "to be after" "${expected}" return fi @@ -164,10 +155,7 @@ function assert_date_within_range() { actual="$(bashunit::date::to_epoch "$3")" if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to be between" "${from} and ${to}" + bashunit::assert::fail_with "" "${actual}" "to be between" "${from} and ${to}" return fi @@ -189,10 +177,7 @@ function assert_date_within_delta() { fi if [ "$diff" -gt "$delta" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${actual}" "to be within" "${delta} seconds of ${expected}" + bashunit::assert::fail_with "" "${actual}" "to be within" "${delta} seconds of ${expected}" return fi diff --git a/src/assert_duration.sh b/src/assert_duration.sh index af290e9c..99266550 100644 --- a/src/assert_duration.sh +++ b/src/assert_duration.sh @@ -27,10 +27,7 @@ function assert_duration() { elapsed_ms=$(bashunit::duration::measure_ms "$command") if [ "$elapsed_ms" -gt "$threshold_ms" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${threshold_ms}" "to complete within (ms)" "${command}" + bashunit::assert::fail_with "" "${threshold_ms}" "to complete within (ms)" "${command}" return fi @@ -47,10 +44,7 @@ function assert_duration_less_than() { elapsed_ms=$(bashunit::duration::measure_ms "$command") if [ "$elapsed_ms" -ge "$threshold_ms" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${threshold_ms}" "to complete within (ms)" "${command}" + bashunit::assert::fail_with "" "${threshold_ms}" "to complete within (ms)" "${command}" return fi @@ -67,10 +61,7 @@ function assert_duration_greater_than() { elapsed_ms=$(bashunit::duration::measure_ms "$command") if [ "$elapsed_ms" -le "$threshold_ms" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${threshold_ms}" "to take at least (ms)" "${command}" + bashunit::assert::fail_with "" "${threshold_ms}" "to take at least (ms)" "${command}" return fi diff --git a/src/assert_files.sh b/src/assert_files.sh index 757ff09a..04ac6332 100644 --- a/src/assert_files.sh +++ b/src/assert_files.sh @@ -6,10 +6,7 @@ function assert_file_exists() { local expected="$1" if [ ! -f "$expected" ]; then - bashunit::assert::label_to_slot "${3:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to exist but" "do not exist" + bashunit::assert::fail_with "${3:-}" "${expected}" "to exist but" "do not exist" return fi @@ -22,10 +19,7 @@ function assert_file_not_exists() { local expected="$1" if [ -f "$expected" ]; then - bashunit::assert::label_to_slot "${3:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to not exist but" "the file exists" + bashunit::assert::fail_with "${3:-}" "${expected}" "to not exist but" "the file exists" return fi @@ -38,10 +32,7 @@ function assert_is_file() { local expected="$1" if [ ! -f "$expected" ]; then - bashunit::assert::label_to_slot "${3:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to be a file" "but is not a file" + bashunit::assert::fail_with "${3:-}" "${expected}" "to be a file" "but is not a file" return fi @@ -54,10 +45,7 @@ function assert_is_file_empty() { local expected="$1" if [ -s "$expected" ]; then - bashunit::assert::label_to_slot "${3:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to be empty" "but is not empty" + bashunit::assert::fail_with "${3:-}" "${expected}" "to be empty" "but is not empty" return fi @@ -71,11 +59,7 @@ function assert_files_equals() { local actual="$2" if [ "$(diff -u "$expected" "$actual")" != '' ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - - bashunit::console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ + bashunit::assert::fail_with "" "${expected}" "Compared" "${actual}" \ "Diff" "$(diff -u "$expected" "$actual" | sed '1,2d')" return fi @@ -90,11 +74,7 @@ function assert_files_not_equals() { local actual="$2" if [ "$(diff -u "$expected" "$actual")" = '' ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - - bashunit::console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ + bashunit::assert::fail_with "" "${expected}" "Compared" "${actual}" \ "Diff" "Files are equals" return fi @@ -109,11 +89,7 @@ function assert_file_contains() { local string="$2" if ! grep -F -q "$string" "$file"; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - - bashunit::console_results::print_failed_test "${label}" "${file}" "to contain" "${string}" + bashunit::assert::fail_with "" "${file}" "to contain" "${string}" return fi @@ -127,11 +103,7 @@ function assert_file_not_contains() { local string="$2" if grep -q "$string" "$file"; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - - bashunit::console_results::print_failed_test "${label}" "${file}" "to not contain" "${string}" + bashunit::assert::fail_with "" "${file}" "to not contain" "${string}" return fi @@ -160,13 +132,10 @@ function assert_file_permissions() { local expected="$1" local file="$2" - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT if [ ! -e "$file" ]; then - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "${label}" "${file}" "to have permissions ${expected}" "but the file does not exist" + bashunit::assert::fail_with "" "${file}" \ + "to have permissions ${expected}" "but the file does not exist" return fi @@ -178,9 +147,8 @@ function assert_file_permissions() { actual_dec="$(bashunit::assert::_octal_to_decimal "$actual")" if [ "$expected_dec" != "$actual_dec" ]; then - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test \ - "${label}" "${file}" "to have permissions ${expected}" "but got ${actual}" + bashunit::assert::fail_with "" "${file}" \ + "to have permissions ${expected}" "but got ${actual}" return fi diff --git a/src/assert_folders.sh b/src/assert_folders.sh index 369b21d2..c8751f85 100644 --- a/src/assert_folders.sh +++ b/src/assert_folders.sh @@ -6,10 +6,7 @@ function assert_directory_exists() { local expected="$1" if [ ! -d "$expected" ]; then - bashunit::assert::label_to_slot "${2:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to exist but" "do not exist" + bashunit::assert::fail_with "${2:-}" "${expected}" "to exist but" "do not exist" return fi @@ -22,10 +19,7 @@ function assert_directory_not_exists() { local expected="$1" if [ -d "$expected" ]; then - bashunit::assert::label_to_slot "${2:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to not exist but" "the directory exists" + bashunit::assert::fail_with "${2:-}" "${expected}" "to not exist but" "the directory exists" return fi @@ -38,10 +32,7 @@ function assert_is_directory() { local expected="$1" if [ ! -d "$expected" ]; then - bashunit::assert::label_to_slot "${2:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to be a directory" "but is not a directory" + bashunit::assert::fail_with "${2:-}" "${expected}" "to be a directory" "but is not a directory" return fi @@ -54,10 +45,7 @@ function assert_is_directory_empty() { local expected="$1" if [ ! -d "$expected" ] || [ -n "$(ls -A "$expected")" ]; then - bashunit::assert::label_to_slot "${2:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to be empty" "but is not empty" + bashunit::assert::fail_with "${2:-}" "${expected}" "to be empty" "but is not empty" return fi @@ -70,10 +58,7 @@ function assert_is_directory_not_empty() { local expected="$1" if [ ! -d "$expected" ] || [ -z "$(ls -A "$expected")" ]; then - bashunit::assert::label_to_slot "${2:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to not be empty" "but is empty" + bashunit::assert::fail_with "${2:-}" "${expected}" "to not be empty" "but is empty" return fi @@ -86,10 +71,7 @@ function assert_is_directory_readable() { local expected="$1" if [ ! -d "$expected" ] || [ ! -r "$expected" ] || [ ! -x "$expected" ]; then - bashunit::assert::label_to_slot "${2:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to be readable" "but is not readable" + bashunit::assert::fail_with "${2:-}" "${expected}" "to be readable" "but is not readable" return fi @@ -102,10 +84,7 @@ function assert_is_directory_not_readable() { local expected="$1" if [ ! -d "$expected" ] || { [ -r "$expected" ] && [ -x "$expected" ]; }; then - bashunit::assert::label_to_slot "${2:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to be not readable" "but is readable" + bashunit::assert::fail_with "${2:-}" "${expected}" "to be not readable" "but is readable" return fi @@ -118,10 +97,7 @@ function assert_is_directory_writable() { local expected="$1" if [ ! -d "$expected" ] || [ ! -w "$expected" ]; then - bashunit::assert::label_to_slot "${2:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to be writable" "but is not writable" + bashunit::assert::fail_with "${2:-}" "${expected}" "to be writable" "but is not writable" return fi @@ -134,10 +110,7 @@ function assert_is_directory_not_writable() { local expected="$1" if [ ! -d "$expected" ] || [ -w "$expected" ]; then - bashunit::assert::label_to_slot "${2:-}" - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "to be not writable" "but is writable" + bashunit::assert::fail_with "${2:-}" "${expected}" "to be not writable" "but is writable" return fi diff --git a/src/assert_json.sh b/src/assert_json.sh index 3ddf55ef..2fb7f419 100644 --- a/src/assert_json.sh +++ b/src/assert_json.sh @@ -17,10 +17,7 @@ function assert_json_key_exists() { local result if ! result=$(printf '%s' "$json" | jq -e "$key" 2>/dev/null) || [ "$result" = "null" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${json}" "to have key" "${key}" + bashunit::assert::fail_with "" "${json}" "to have key" "${key}" return fi @@ -37,18 +34,12 @@ function assert_json_contains() { local result if ! result=$(printf '%s' "$json" | jq -e -r "$key" 2>/dev/null) || [ "$result" = "null" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${json}" "to have key" "${key}" + bashunit::assert::fail_with "" "${json}" "to have key" "${key}" return fi if [ "$result" != "$expected" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${result}" + bashunit::assert::fail_with "" "${expected}" "but got " "${result}" return fi @@ -68,10 +59,7 @@ function assert_json_equals() { actual_sorted=$(printf '%s' "$actual" | jq -S '.' 2>/dev/null) if [ "$expected_sorted" != "$actual_sorted" ]; then - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${actual}" + bashunit::assert::fail_with "" "${expected}" "but got " "${actual}" return fi diff --git a/src/bashunit.sh b/src/bashunit.sh index 48f6a99b..bc111797 100644 --- a/src/bashunit.sh +++ b/src/bashunit.sh @@ -11,10 +11,7 @@ function bashunit::assertion_failed() { local actual=$2 local failure_condition_message=${3:-"but got "} - bashunit::assert::label_to_slot - local label=$_BASHUNIT_ASSERT_LABEL_OUT - bashunit::assert::mark_failed - bashunit::console_results::print_failed_test "${label}" "${expected}" \ + bashunit::assert::fail_with "" "${expected}" \ "$failure_condition_message" "${actual}" } diff --git a/src/console_results.sh b/src/console_results.sh index e6d685a9..4db4ff2f 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -152,25 +152,40 @@ function bashunit::console_results::print_execution_time() { "Time taken: ${formatted}" } -function bashunit::console_results::format_duration() { +_BASHUNIT_CONSOLE_DURATION_OUT="" + +## +# Writes a human-readable duration (Xm Ys / X.XXs / Xms) into +# _BASHUNIT_CONSOLE_DURATION_OUT. Fork-free, so per-test render paths can format +# a duration without a $(...) capture. +# Arguments: $1 - duration in milliseconds +## +function bashunit::console_results::format_duration_to_slot() { local duration_ms="$1" if [ "$duration_ms" -ge 60000 ]; then local time_in_seconds=$((duration_ms / 1000)) local minutes=$((time_in_seconds / 60)) local seconds=$((time_in_seconds % 60)) - echo "${minutes}m ${seconds}s" + _BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s" elif [ "$duration_ms" -ge 1000 ]; then local integer_part=$((duration_ms / 1000)) - local decimal_part=$(( (duration_ms % 1000) / 10 )) - local formatted_seconds - formatted_seconds=$(printf "%d.%02d" "$integer_part" "$decimal_part") - echo "${formatted_seconds}s" + local decimal_part=$(((duration_ms % 1000) / 10)) + # Pad the hundredths by hand: printf would cost a fork on this hot path. + if [ "$decimal_part" -lt 10 ]; then + decimal_part="0${decimal_part}" + fi + _BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s" else - echo "${duration_ms}ms" + _BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms" fi } +function bashunit::console_results::format_duration() { + bashunit::console_results::format_duration_to_slot "$1" + echo "$_BASHUNIT_CONSOLE_DURATION_OUT" +} + function bashunit::console_results::print_hook_completed() { local hook_name="$1" local duration_ms="$2" @@ -235,22 +250,8 @@ function bashunit::console_results::print_successful_test() { local full_line=$line if bashunit::env::is_show_execution_time_enabled; then - local time_display - if [ "$duration" -ge 60000 ]; then - local time_in_seconds=$((duration / 1000)) - local minutes=$((time_in_seconds / 60)) - local seconds=$((time_in_seconds % 60)) - time_display="${minutes}m ${seconds}s" - elif [ "$duration" -ge 1000 ]; then - local integer_part=$((duration / 1000)) - local decimal_part=$(( (duration % 1000) / 10 )) - local formatted_seconds - formatted_seconds=$(printf "%d.%02d" "$integer_part" "$decimal_part") - time_display="${formatted_seconds}s" - else - time_display="${duration}ms" - fi - full_line="$(bashunit::str::rpad "$line" "$time_display")" + bashunit::console_results::format_duration_to_slot "$duration" + full_line="$(bashunit::str::rpad "$line" "$_BASHUNIT_CONSOLE_DURATION_OUT")" fi bashunit::state::print_line "successful" "$full_line" diff --git a/src/coverage.sh b/src/coverage.sh index 7c3b6c27..975e21eb 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -604,20 +604,6 @@ function bashunit::coverage::get_hit_lines() { echo "$count" } -function bashunit::coverage::get_line_hits() { - local file="$1" - local lineno="$2" - - if [ ! -f "$_BASHUNIT_COVERAGE_DATA_FILE" ]; then - echo "0" - return - fi - - local count - count=$("$GREP" -c "^${file}:${lineno}$" "$_BASHUNIT_COVERAGE_DATA_FILE" 2>/dev/null) || count=0 - echo "$count" -} - # Compute executable + hit counts for a file in a single source-file pass. # Reuses get_all_line_hits to avoid scanning the coverage data per line. # Output format: "executable:hit" @@ -876,8 +862,9 @@ function bashunit::coverage::_is_case_pattern_line() { # Extract branch points from a Bash file. # Output format: ||:[,:]... -# kind ∈ {if, case} -# Scope: if/elif/else chains and case patterns. See adrs/adr-007-branch-coverage-mvp.md. +# kind ∈ {if, case, loop} +# Scope: if/elif/else chains, case patterns and loop bodies. +# See adrs/adr-007-branch-coverage-mvp.md. # The handlers below operate on the per-construct state arrays that # extract_branches keeps as locals. Bash 3.0 has dynamic scoping for # `local` vars, so the helpers see and mutate the caller's state diff --git a/src/dependencies.sh b/src/dependencies.sh index 1d12c034..fd8ed95d 100644 --- a/src/dependencies.sh +++ b/src/dependencies.sh @@ -9,10 +9,6 @@ function bashunit::dependencies::has_powershell() { command -v powershell >/dev/null 2>&1 } -function bashunit::dependencies::has_adjtimex() { - command -v adjtimex >/dev/null 2>&1 -} - function bashunit::dependencies::has_bc() { command -v bc >/dev/null 2>&1 } diff --git a/src/doc.sh b/src/doc.sh index 661ed472..f168af14 100644 --- a/src/doc.sh +++ b/src/doc.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# This function returns the embedded assertions.md content. -# During development, it reads from the file. -# During build, this function is replaced with actual content. +# Returns the assertions.md content. In a repo checkout it reads the file; +# build.sh swaps everything between the two marker comments below for a heredoc +# holding the docs verbatim, so the single-file binary needs no docs/ directory. +# The markers are load-bearing: build::embed_docs aborts if either is missing. function bashunit::doc::get_embedded_docs() { # __BASHUNIT_EMBEDDED_DOCS_START__ cat "$BASHUNIT_ROOT_DIR/docs/assertions.md" diff --git a/src/env.sh b/src/env.sh index 387a8665..e5195e6a 100644 --- a/src/env.sh +++ b/src/env.sh @@ -86,6 +86,8 @@ _BASHUNIT_DEFAULT_COVERAGE_REPORT_HTML="" _BASHUNIT_DEFAULT_COVERAGE_MIN="" _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW="50" _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH="80" +# Per-line execution counts in the text coverage report (#856) +_BASHUNIT_DEFAULT_COVERAGE_SHOW_LINE_HITS="false" : "${BASHUNIT_DEFAULT_PATH:=${DEFAULT_PATH:=$_BASHUNIT_DEFAULT_DEFAULT_PATH}}" : "${BASHUNIT_DEV_LOG:=${DEV_LOG:=$_BASHUNIT_DEFAULT_DEV_LOG}}" @@ -112,9 +114,15 @@ BASHUNIT_WATCH_INTERVAL=$(bashunit::env::positive_int_or_default \ : "${BASHUNIT_COVERAGE_MIN:=${COVERAGE_MIN:=$_BASHUNIT_DEFAULT_COVERAGE_MIN}}" : "${BASHUNIT_COVERAGE_THRESHOLD_LOW:=${COVERAGE_THRESHOLD_LOW:=$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}}" : "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:=${COVERAGE_THRESHOLD_HIGH:=$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}}" +# No bare COVERAGE_SHOW_LINE_HITS alias: registering the default here is a +# no-op consolidation, whereas adding the alias would widen the public API. +# bashunit::coverage keeps its :- guard for callers that unset it. +: "${BASHUNIT_COVERAGE_SHOW_LINE_HITS:=$_BASHUNIT_DEFAULT_COVERAGE_SHOW_LINE_HITS}" # Booleans _BASHUNIT_DEFAULT_PARALLEL_RUN="false" +# Worker cap for --parallel (0 = unbounded) +_BASHUNIT_DEFAULT_PARALLEL_JOBS="0" _BASHUNIT_DEFAULT_SHOW_HEADER="true" _BASHUNIT_DEFAULT_HEADER_ASCII_ART="false" _BASHUNIT_DEFAULT_SIMPLE_OUTPUT="false" @@ -151,9 +159,11 @@ _BASHUNIT_DEFAULT_SEED="" # Shard / to split the suite across runners (empty = disabled) _BASHUNIT_DEFAULT_SHARD_INDEX="" _BASHUNIT_DEFAULT_SHARD_TOTAL="" +# Replay only the tests recorded as failing by the previous run +_BASHUNIT_DEFAULT_RERUN_FAILED="false" : "${BASHUNIT_PARALLEL_RUN:=${PARALLEL_RUN:=$_BASHUNIT_DEFAULT_PARALLEL_RUN}}" -: "${BASHUNIT_PARALLEL_JOBS:=0}" +: "${BASHUNIT_PARALLEL_JOBS:=$_BASHUNIT_DEFAULT_PARALLEL_JOBS}" : "${BASHUNIT_SHOW_HEADER:=${SHOW_HEADER:=$_BASHUNIT_DEFAULT_SHOW_HEADER}}" : "${BASHUNIT_HEADER_ASCII_ART:=${HEADER_ASCII_ART:=$_BASHUNIT_DEFAULT_HEADER_ASCII_ART}}" : "${BASHUNIT_SIMPLE_OUTPUT:=${SIMPLE_OUTPUT:=$_BASHUNIT_DEFAULT_SIMPLE_OUTPUT}}" @@ -187,6 +197,10 @@ _BASHUNIT_DEFAULT_SHARD_TOTAL="" : "${BASHUNIT_SEED:=$_BASHUNIT_DEFAULT_SEED}" : "${BASHUNIT_SHARD_INDEX:=$_BASHUNIT_DEFAULT_SHARD_INDEX}" : "${BASHUNIT_SHARD_TOTAL:=$_BASHUNIT_DEFAULT_SHARD_TOTAL}" +# No bare RERUN_FAILED alias, same reasoning as RETRY/SEED above. The default +# lives here rather than inline in rerun.sh so every BASHUNIT_* default has one +# home; bashunit::rerun::is_enabled keeps its :- guard for callers that unset it. +: "${BASHUNIT_RERUN_FAILED:=$_BASHUNIT_DEFAULT_RERUN_FAILED}" # Support NO_COLOR standard (https://no-color.org) if [ -n "${NO_COLOR:-}" ]; then BASHUNIT_NO_COLOR="true" @@ -298,6 +312,50 @@ function bashunit::env::is_internal_log_enabled() { [ "$BASHUNIT_INTERNAL_LOG" = "true" ] } +## +# Dev-log writers. +# +# They live next to BASHUNIT_DEV_LOG/BASHUNIT_INTERNAL_LOG and their predicates +# rather than in globals.sh: env.sh is the lowest layer and logs from its own +# source-time code, so keeping the writers here removes the env.sh <-> globals.sh +# call cycle instead of papering over it with a duplicated predicate. +## +function bashunit::current_timestamp() { + date +"%Y-%m-%d %H:%M:%S" +} + +# shellcheck disable=SC2145 +function bashunit::log() { + if ! bashunit::env::is_dev_mode_enabled; then + return + fi + + local level="$1" + shift + + case "$level" in + info | INFO) level="INFO" ;; + debug | DEBUG) level="DEBUG" ;; + warning | WARNING) level="WARNING" ;; + critical | CRITICAL) level="CRITICAL" ;; + error | ERROR) level="ERROR" ;; + *) + set -- "$level $@" + level="INFO" + ;; + esac + + echo "$(bashunit::current_timestamp) [$level]: $* #${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >>"$BASHUNIT_DEV_LOG" +} + +function bashunit::internal_log() { + if ! bashunit::env::is_dev_mode_enabled || ! bashunit::env::is_internal_log_enabled; then + return + fi + + echo "$(bashunit::current_timestamp) [INTERNAL]: $* #${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >>"$BASHUNIT_DEV_LOG" +} + function bashunit::env::is_verbose_enabled() { [ "$BASHUNIT_VERBOSE" = "true" ] } @@ -419,7 +477,7 @@ function bashunit::env::active_internet_connection() { function bashunit::env::find_terminal_width() { local cols="" - if [ -z "$cols" ] && command -v tput >/dev/null; then + if command -v tput >/dev/null; then cols=$(tput cols 2>/dev/null) fi @@ -508,8 +566,39 @@ RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed" # Shared temp directory, initialized once at startup for performance. BASHUNIT_TEMP_DIR="${TMPDIR:-/tmp}/bashunit/tmp" -# Create both scratch directories in a single `mkdir -p` fork. -mkdir -p "$_BASHUNIT_RUN_OUTPUT_DIR" "$BASHUNIT_TEMP_DIR" 2>/dev/null || true +## +# Creates both scratch directories in a single `mkdir -p` fork. +# +# This must not fail silently: every deferred-output collector (failures, +# skipped, incomplete, risky, rerun) and every `temp_file`/`temp_dir` call +# writes under these paths, and each of them appends with `>>` or guards reads +# with `[ -s ]`. Without the directories those writes are all no-ops, so a red +# suite would render as a green one — the worst possible failure mode for a +# test runner. Abort with an actionable message instead. +# +# Arguments: $1 run output dir, $2 shared temp dir +# Returns: 0 when both directories exist, 1 otherwise (message on stderr) +## +function bashunit::env::create_scratch_dirs() { + local run_dir=$1 + local temp_dir=$2 + + # `mkdir -p` may race a sibling bashunit on the shared temp dir, so its exit + # code alone is not conclusive; the postcondition below is what decides. Its + # stderr is deliberately NOT silenced, so a real failure stays visible. + mkdir -p "$run_dir" "$temp_dir" || true + + local dir + for dir in "$run_dir" "$temp_dir"; do + if [ ! -d "$dir" ]; then + printf 'bashunit: cannot create the scratch directory: %s\n' "$dir" >&2 + printf 'bashunit: set TMPDIR to a writable location and try again.\n' >&2 + return 1 + fi + done +} + +bashunit::env::create_scratch_dirs "$_BASHUNIT_RUN_OUTPUT_DIR" "$BASHUNIT_TEMP_DIR" || exit 1 # Removes this run's scratch directory (guarded like parallel::cleanup so a # broken variable can never turn the rm loose elsewhere). Called at the end of diff --git a/src/globals.sh b/src/globals.sh index ccaa78c1..d423e873 100644 --- a/src/globals.sh +++ b/src/globals.sh @@ -19,25 +19,10 @@ function bashunit::caller_line() { echo "${BASH_LINENO[1]}" } -function bashunit::current_timestamp() { - date +"%Y-%m-%d %H:%M:%S" -} - function bashunit::is_command_available() { command -v "$1" >/dev/null 2>&1 } -function bashunit::random_str() { - local length=${1:-6} - local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - local str='' - local i - for ((i = 0; i < length; i++)); do - str="$str${chars:RANDOM%${#chars}:1}" - done - echo "$str" -} - function bashunit::temp_file() { local prefix=${1:-bashunit} local test_prefix="" @@ -92,38 +77,6 @@ function bashunit::cleanup_script_temp_files() { fi } -# shellcheck disable=SC2145 -function bashunit::log() { - if ! bashunit::env::is_dev_mode_enabled; then - return - fi - - local level="$1" - shift - - case "$level" in - info | INFO) level="INFO" ;; - debug | DEBUG) level="DEBUG" ;; - warning | WARNING) level="WARNING" ;; - critical | CRITICAL) level="CRITICAL" ;; - error | ERROR) level="ERROR" ;; - *) - set -- "$level $@" - level="INFO" - ;; - esac - - echo "$(bashunit::current_timestamp) [$level]: $* #${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >>"$BASHUNIT_DEV_LOG" -} - -function bashunit::internal_log() { - if ! bashunit::env::is_dev_mode_enabled || ! bashunit::env::is_internal_log_enabled; then - return - fi - - echo "$(bashunit::current_timestamp) [INTERNAL]: $* #${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >>"$BASHUNIT_DEV_LOG" -} - function bashunit::print_line() { local length="${1:-70}" # Default to 70 if not passed local char="${2:--}" # Default to '-' if not passed diff --git a/src/helpers.sh b/src/helpers.sh index 555a5463..5d0b95ec 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -326,10 +326,22 @@ function bashunit::helper::find_files_recursive() { local _has_glob=false case "$path" in *"*"*) _has_glob=true ;; esac if [ "$_has_glob" = true ]; then + # Expand the glob into an array WITHOUT `eval`: setting IFS to the empty + # string disables field splitting, so the unquoted expansion below performs + # pathname expansion only. `eval "find $path ..."` also word-split on spaces, + # which turned "my dir/*" into the two roots "my" and "dir/*". A non-matching + # glob stays literal (nullglob is off), matching the previous behaviour of + # handing the unexpanded pattern to find. + local _old_ifs=$IFS + IFS='' + local _roots + # shellcheck disable=SC2206 # pathname expansion is the point; IFS='' blocks splitting + _roots=($path) + IFS=$_old_ifs if [ -n "$alt_pattern" ]; then - eval "find $path -type f \( -name \"$pattern\" -o -name \"$alt_pattern\" \)" | sort -u + find "${_roots[@]}" -type f \( -name "$pattern" -o -name "$alt_pattern" \) | sort -u else - eval "find $path -type f -name \"$pattern\"" | sort -u + find "${_roots[@]}" -type f -name "$pattern" | sort -u fi elif [ -d "$path" ]; then if [ -n "$alt_pattern" ]; then @@ -501,16 +513,6 @@ function bashunit::helper::get_provider_data() { fi } -function bashunit::helper::trim() { - local input_string="$1" - local trimmed_string - - trimmed_string="${input_string#"${input_string%%[![:space:]]*}"}" - trimmed_string="${trimmed_string%"${trimmed_string##*[![:space:]]}"}" - - echo "$trimmed_string" -} - function bashunit::helper::get_latest_tag() { if ! bashunit::dependencies::has_git; then return 1 diff --git a/src/main.sh b/src/main.sh index cf0c5b24..7ab7f50d 100644 --- a/src/main.sh +++ b/src/main.sh @@ -850,8 +850,9 @@ function bashunit::main::exec_tests() { bashunit::parallel::resolve_enabled if bashunit::env::is_parallel_run_enabled && ! bashunit::parallel::is_enabled; then - printf "%sWarning: Parallel tests are supported on macOS, Ubuntu and Windows.\n" "${_BASHUNIT_COLOR_INCOMPLETE}" - printf "For other OS (like Alpine), --parallel is not enabled due to inconsistent results,\n" + printf "%sWarning: Parallel tests are supported on macOS, Ubuntu, Alpine and Windows.\n" \ + "${_BASHUNIT_COLOR_INCOMPLETE}" + printf "On other systems --parallel is not enabled due to inconsistent results,\n" printf "particularly involving race conditions.%s " "${_BASHUNIT_COLOR_DEFAULT}" printf "%sFallback using --no-parallel%s\n" "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" fi @@ -1072,7 +1073,7 @@ function bashunit::main::exec_assert() { args[last_index]="$inner_exit_code" ;; *) - # Add more cases here for other assert_* handlers if needed + # Every other assertion takes its argument as-is; no rewriting needed. ;; esac diff --git a/src/parallel.sh b/src/parallel.sh index 9ea33529..5351c357 100755 --- a/src/parallel.sh +++ b/src/parallel.sh @@ -1,121 +1,5 @@ #!/usr/bin/env bash -function bashunit::parallel::aggregate_test_results() { - local temp_dir_parallel_test_suite=$1 - local IFS=$' \t\n' - - bashunit::internal_log "aggregate_test_results" "dir:$temp_dir_parallel_test_suite" - - local total_failed=0 - local total_passed=0 - local total_skipped=0 - local total_incomplete=0 - local total_snapshot=0 - - local script_dir="" - for script_dir in "$temp_dir_parallel_test_suite"/*; do - shopt -s nullglob - # Bash 3.0 compatible: separate declaration and assignment for arrays - local result_files - result_files=("$script_dir"/*.result) - shopt -u nullglob - - if [ ${#result_files[@]} -eq 0 ]; then - printf "%sNo tests found%s" "$_BASHUNIT_COLOR_SKIPPED" "$_BASHUNIT_COLOR_DEFAULT" - continue - fi - - local result_file="" - for result_file in "${result_files[@]+"${result_files[@]}"}"; do - local result_line - result_line=$(<"$result_file") - result_line="${result_line##*$'\n'}" - - local failed="${result_line##*##ASSERTIONS_FAILED=}" - failed="${failed%%##*}" - failed=${failed:-0} - - local passed="${result_line##*##ASSERTIONS_PASSED=}" - passed="${passed%%##*}" - passed=${passed:-0} - - local skipped="${result_line##*##ASSERTIONS_SKIPPED=}" - skipped="${skipped%%##*}" - skipped=${skipped:-0} - - local incomplete="${result_line##*##ASSERTIONS_INCOMPLETE=}" - incomplete="${incomplete%%##*}" - incomplete=${incomplete:-0} - - local snapshot="${result_line##*##ASSERTIONS_SNAPSHOT=}" - snapshot="${snapshot%%##*}" - snapshot=${snapshot:-0} - - local exit_code="${result_line##*##TEST_EXIT_CODE=}" - exit_code="${exit_code%%##*}" - exit_code=${exit_code:-0} - - # Add to the total counts - total_failed=$((total_failed + failed)) - total_passed=$((total_passed + passed)) - total_skipped=$((total_skipped + skipped)) - total_incomplete=$((total_incomplete + incomplete)) - total_snapshot=$((total_snapshot + snapshot)) - - if [ "${failed:-0}" -gt 0 ]; then - bashunit::state::add_tests_failed - continue - fi - - if [ "${exit_code:-0}" -ne 0 ]; then - bashunit::state::add_tests_failed - continue - fi - - if [ "${snapshot:-0}" -gt 0 ]; then - bashunit::state::add_tests_snapshot - continue - fi - - if [ "${incomplete:-0}" -gt 0 ]; then - bashunit::state::add_tests_incomplete - continue - fi - - if [ "${skipped:-0}" -gt 0 ]; then - bashunit::state::add_tests_skipped - continue - fi - - # Check for risky test (zero assertions, no error) - local total_for_test=$((failed + passed + skipped + incomplete + snapshot)) - if [ "$total_for_test" -eq 0 ] && [ "${exit_code:-0}" -eq 0 ]; then - if bashunit::env::is_fail_on_risky_enabled; then - bashunit::state::add_tests_failed - else - bashunit::state::add_tests_risky - fi - continue - fi - - bashunit::state::add_tests_passed - done - done - - export _BASHUNIT_ASSERTIONS_FAILED=$total_failed - export _BASHUNIT_ASSERTIONS_PASSED=$total_passed - export _BASHUNIT_ASSERTIONS_SKIPPED=$total_skipped - export _BASHUNIT_ASSERTIONS_INCOMPLETE=$total_incomplete - export _BASHUNIT_ASSERTIONS_SNAPSHOT=$total_snapshot - - bashunit::internal_log "aggregate_totals" \ - "failed:$total_failed" \ - "passed:$total_passed" \ - "skipped:$total_skipped" \ - "incomplete:$total_incomplete" \ - "snapshot:$total_snapshot" -} - function bashunit::parallel::mark_stop_on_failure() { touch "$TEMP_FILE_PARALLEL_STOP_ON_FAILURE" } diff --git a/src/runner.sh b/src/runner.sh index 6e103a6b..ae58f032 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -1,15 +1,26 @@ #!/usr/bin/env bash # shellcheck disable=SC2155 -# Pre-compiled regex pattern for parsing test result assertions -if [ -z "${_BASHUNIT_RUNNER_PARSE_RESULT_REGEX+x}" ]; then - declare -r _BASHUNIT_RUNNER_PARSE_RESULT_REGEX='ASSERTIONS_FAILED=([0-9]*)##'\ -'ASSERTIONS_PASSED=([0-9]*)##ASSERTIONS_SKIPPED=([0-9]*)##'\ -'ASSERTIONS_INCOMPLETE=([0-9]*)##ASSERTIONS_SNAPSHOT=([0-9]*)##TEST_EXIT_CODE=([0-9]*)' -fi - +## +# Returns to the directory bashunit was started from, undoing any `cd` a test +# file performed in `set_up_before_script` (#532). +# +# A failure here is not recoverable: every later test file is discovered and +# sourced through a path relative to this directory, so silently staying put +# would drop the remaining files from the run without a single error. Abort +# loudly instead. +# +# Arguments: $1 (optional) directory to restore, defaults to BASHUNIT_WORKING_DIR +## function bashunit::runner::restore_workdir() { - cd "$BASHUNIT_WORKING_DIR" 2>/dev/null || true + local target="${1:-${BASHUNIT_WORKING_DIR:-}}" + if cd "$target" 2>/dev/null; then + return 0 + fi + + printf "%sError: cannot restore the working directory '%s'. Aborting run.%s\n" \ + "${_BASHUNIT_COLOR_FAILED:-}" "$target" "${_BASHUNIT_COLOR_DEFAULT:-}" >&2 + exit 1 } ## @@ -178,9 +189,17 @@ function bashunit::runner::compute_total_assertions() { incomplete="${incomplete%%##*}" snapshot="${test_execution_result##*##ASSERTIONS_SNAPSHOT=}" snapshot="${snapshot%%##*}" + # A result that never reached the payload (a SIGKILLed subshell, raw stderr) + # leaves every ##KEY= strip a no-op, so these fields hold arbitrary text. That + # text is not "0" to `$(( ))`: it is a fatal arithmetic syntax error that + # aborts the run. One `case` over the concatenation costs no fork and keeps + # the happy path (all digits, or empty for an absent counter) untouched. + case "$failed$passed$skipped$incomplete$snapshot" in + *[!0-9]*) failed=0 passed=0 skipped=0 incomplete=0 snapshot=0 ;; + esac local total - total=$((${failed:-0} + ${passed:-0} + ${skipped:-0})) - total=$((total + ${incomplete:-0} + ${snapshot:-0})) + total=$((failed + passed + skipped)) + total=$((total + incomplete + snapshot)) _BASHUNIT_RUNNER_TOTAL_OUT=$total } @@ -217,6 +236,23 @@ function bashunit::runner::record_profile() { printf '%s\t%s\t%s\n' "$duration" "$test_name" "$test_file" >>"$PROFILE_OUTPUT_PATH" } +## +# Honours --stop-on-failure once a test has been recorded as failed. A parallel +# worker raises the shared flag file (the dispatcher checks it between tests) +# rather than exiting, since exiting would only kill the worker. A sequential +# run exits with EXIT_CODE_STOP_ON_FAILURE, which main.sh's EXIT trap turns +# into the final summary. No-op when the flag is off. +## +function bashunit::runner::halt_if_stop_on_failure() { + bashunit::env::is_stop_on_failure_enabled || return 0 + + if bashunit::parallel::is_enabled; then + bashunit::parallel::mark_stop_on_failure + else + exit "$EXIT_CODE_STOP_ON_FAILURE" + fi +} + # Writes the detected runtime-error message (empty when none) into # _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT. Return-slot form avoids a per-test fork # on the hot path (#764). @@ -502,13 +538,9 @@ function bashunit::runner::load_test_files() { local _cached_fns="$functions_for_script" if bashunit::parallel::is_enabled; then bashunit::runner::wait_for_job_slot - bashunit::runner::call_test_functions \ - "$test_file" "$filter" "$tag_filter" \ - "$exclude_tag_filter" "$_cached_fns" 2>/dev/null & + bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>/dev/null & else - bashunit::runner::call_test_functions \ - "$test_file" "$filter" "$tag_filter" \ - "$exclude_tag_filter" "$_cached_fns" + bashunit::runner::call_test_functions "$test_file" "$_cached_fns" fi bashunit::runner::run_tear_down_after_script "$test_file" bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" @@ -524,7 +556,7 @@ function bashunit::runner::load_test_files() { wait bashunit::runner::spinner & local spinner_pid=$! - bashunit::parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE" + bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" # Kill the spinner once the aggregation finishes disown "$spinner_pid" 2>/dev/null || true kill "$spinner_pid" 2>/dev/null || true @@ -775,54 +807,26 @@ function bashunit::runner::parse_data_provider_args() { done } +## +# Runs the given test functions of a script (sequentially, or one background +# worker per test under --parallel). +# Arguments: $1 script path, $2 space-separated test function names, already +# filter/tag/rerun-filtered by load_test_files (never empty: the caller skips +# the file when no function survives filtering). +## function bashunit::runner::call_test_functions() { local script="$1" - local filter="$2" - local tag_filter="${3:-}" - local exclude_tag_filter="${4:-}" - local cached_functions="${5:-}" + local cached_functions="${2:-}" local IFS=$' \t\n' local -a functions_to_run=() local functions_to_run_count=0 - if [ -n "$cached_functions" ]; then - # Use pre-computed function list from load_test_files (already tag-filtered) - local _fn - for _fn in $cached_functions; do - [ -z "$_fn" ] && continue - functions_to_run[functions_to_run_count]="$_fn" - functions_to_run_count=$((functions_to_run_count + 1)) - done - else - # Fallback: compute function list (for direct calls without cache) - local prefix="test" - local filtered_functions - filtered_functions=$(bashunit::helper::get_functions_to_run \ - "$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") - local _fn - while IFS= read -r _fn; do - [ -z "$_fn" ] && continue - functions_to_run[functions_to_run_count]="$_fn" - functions_to_run_count=$((functions_to_run_count + 1)) - done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions") - - # Apply tag filtering if --tag or --exclude-tag was specified - if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then - bashunit::helper::build_tags_map "$script" - local -a tag_filtered=() - local tag_filtered_count=0 - local _tf_fn - for _tf_fn in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do - bashunit::helper::tags_for_function "$_tf_fn" - if bashunit::helper::function_matches_tags "$_BASHUNIT_TAGS_OUT" "$tag_filter" "$exclude_tag_filter"; then - tag_filtered[tag_filtered_count]="$_tf_fn" - tag_filtered_count=$((tag_filtered_count + 1)) - fi - done - functions_to_run=("${tag_filtered[@]+"${tag_filtered[@]}"}") - functions_to_run_count=$tag_filtered_count - fi - fi + local _fn + for _fn in $cached_functions; do + [ -z "$_fn" ] && continue + functions_to_run[functions_to_run_count]="$_fn" + functions_to_run_count=$((functions_to_run_count + 1)) + done # Randomize function order within this file. The seed is mixed with a stable # per-file value (cksum of the path) so different files get different orders @@ -1331,13 +1335,7 @@ function bashunit::runner::run_test() { bashunit::runner::write_failure_result_output "$test_file" "$failure_function" "$error_message" "$runtime_output" bashunit::internal_log "Test error" "$failure_label" "$error_message" - if bashunit::env::is_stop_on_failure_enabled; then - if bashunit::parallel::is_enabled; then - bashunit::parallel::mark_stop_on_failure - else - exit "$EXIT_CODE_STOP_ON_FAILURE" - fi - fi + bashunit::runner::halt_if_stop_on_failure return fi @@ -1354,13 +1352,7 @@ function bashunit::runner::run_test() { bashunit::internal_log "Test failed" "$label" - if bashunit::env::is_stop_on_failure_enabled; then - if bashunit::parallel::is_enabled; then - bashunit::parallel::mark_stop_on_failure - else - exit "$EXIT_CODE_STOP_ON_FAILURE" - fi - fi + bashunit::runner::halt_if_stop_on_failure return fi @@ -1401,13 +1393,7 @@ function bashunit::runner::run_test() { bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$risky_msg" bashunit::runner::write_failure_result_output "$test_file" "$fn_name" "$risky_msg" bashunit::internal_log "Test failed (risky)" "$label" - if bashunit::env::is_stop_on_failure_enabled; then - if bashunit::parallel::is_enabled; then - bashunit::parallel::mark_stop_on_failure - else - exit "$EXIT_CODE_STOP_ON_FAILURE" - fi - fi + bashunit::runner::halt_if_stop_on_failure return fi bashunit::state::add_tests_risky diff --git a/src/state.sh b/src/state.sh index c36b74ff..c582e3e6 100644 --- a/src/state.sh +++ b/src/state.sh @@ -255,19 +255,145 @@ function bashunit::state::export_subshell_context() { printf '%s\n' "$payload" } -function bashunit::state::calculate_total_assertions() { - local input="$1" - local total=0 +## +# Folds every parallel worker's `.result` payload back into this shell's +# counters and assertion totals. +# +# Lives here rather than in parallel.sh because it decodes the very payload +# `bashunit::state::export_subshell_context` writes: keeping the encoder and the +# decoder in one module removes the parallel.sh <-> state.sh call cycle and stops +# the format from being described in two places. +# +# Arguments: $1 - the run's parallel temp directory +## +function bashunit::state::aggregate_parallel_results() { + local temp_dir_parallel_test_suite=$1 + local IFS=$' \t\n' + + bashunit::internal_log "aggregate_parallel_results" "dir:$temp_dir_parallel_test_suite" + + local total_failed=0 + local total_passed=0 + local total_skipped=0 + local total_incomplete=0 + local total_snapshot=0 + + local script_dir="" + for script_dir in "$temp_dir_parallel_test_suite"/*; do + shopt -s nullglob + # Bash 3.0 compatible: separate declaration and assignment for arrays + local result_files + result_files=("$script_dir"/*.result) + shopt -u nullglob + + if [ ${#result_files[@]} -eq 0 ]; then + printf "%sNo tests found%s" "$_BASHUNIT_COLOR_SKIPPED" "$_BASHUNIT_COLOR_DEFAULT" + continue + fi + + local result_file="" + for result_file in "${result_files[@]+"${result_files[@]}"}"; do + local result_line + result_line=$(<"$result_file") + result_line="${result_line##*$'\n'}" + + local failed="${result_line##*##ASSERTIONS_FAILED=}" + failed="${failed%%##*}" + failed=${failed:-0} + + local passed="${result_line##*##ASSERTIONS_PASSED=}" + passed="${passed%%##*}" + passed=${passed:-0} + + local skipped="${result_line##*##ASSERTIONS_SKIPPED=}" + skipped="${skipped%%##*}" + skipped=${skipped:-0} + + local incomplete="${result_line##*##ASSERTIONS_INCOMPLETE=}" + incomplete="${incomplete%%##*}" + incomplete=${incomplete:-0} + + local snapshot="${result_line##*##ASSERTIONS_SNAPSHOT=}" + snapshot="${snapshot%%##*}" + snapshot=${snapshot:-0} + + local exit_code="${result_line##*##TEST_EXIT_CODE=}" + exit_code="${exit_code%%##*}" + exit_code=${exit_code:-0} + + # A truncated or non-payload .result line leaves every ##KEY= strip a + # no-op, so these fields hold arbitrary text. `$(( ))` on such text is a + # fatal arithmetic syntax error and `[ -gt ]` reports "integer expression + # expected", so an unreadable result must degrade to zeros rather than + # abort the aggregation. One `case` over the concatenation, no fork. + case "$failed$passed$skipped$incomplete$snapshot$exit_code" in + *[!0-9]*) + failed=0 passed=0 skipped=0 incomplete=0 snapshot=0 + # An unparseable result is a failed test, not a silently passing one. + exit_code=1 + bashunit::internal_log "aggregate_parallel_results" "unparseable result file:$result_file" + ;; + esac + + # Add to the total counts + total_failed=$((total_failed + failed)) + total_passed=$((total_passed + passed)) + total_skipped=$((total_skipped + skipped)) + total_incomplete=$((total_incomplete + incomplete)) + total_snapshot=$((total_snapshot + snapshot)) + + if [ "${failed:-0}" -gt 0 ]; then + bashunit::state::add_tests_failed + continue + fi + + if [ "${exit_code:-0}" -ne 0 ]; then + bashunit::state::add_tests_failed + continue + fi + + if [ "${snapshot:-0}" -gt 0 ]; then + bashunit::state::add_tests_snapshot + continue + fi + + if [ "${incomplete:-0}" -gt 0 ]; then + bashunit::state::add_tests_incomplete + continue + fi - local numbers - numbers=$(echo "$input" | grep -oE '##ASSERTIONS_\w+=[0-9]+' | grep -oE '[0-9]+') + if [ "${skipped:-0}" -gt 0 ]; then + bashunit::state::add_tests_skipped + continue + fi + + # Check for risky test (zero assertions, no error) + local total_for_test=$((failed + passed + skipped + incomplete + snapshot)) + if [ "$total_for_test" -eq 0 ] && [ "${exit_code:-0}" -eq 0 ]; then + if bashunit::env::is_fail_on_risky_enabled; then + bashunit::state::add_tests_failed + else + bashunit::state::add_tests_risky + fi + continue + fi - local number - for number in $numbers; do - total=$((total + number)) + bashunit::state::add_tests_passed + done done - echo $total + export _BASHUNIT_ASSERTIONS_FAILED=$total_failed + export _BASHUNIT_ASSERTIONS_PASSED=$total_passed + export _BASHUNIT_ASSERTIONS_SKIPPED=$total_skipped + export _BASHUNIT_ASSERTIONS_INCOMPLETE=$total_incomplete + export _BASHUNIT_ASSERTIONS_SNAPSHOT=$total_snapshot + + bashunit::internal_log "aggregate_totals" \ + "failed:$total_failed" \ + "passed:$total_passed" \ + "skipped:$total_skipped" \ + "incomplete:$total_incomplete" \ + "snapshot:$total_snapshot" } function bashunit::state::print_line() { diff --git a/src/str.sh b/src/str.sh index 90143eec..a8d3004e 100644 --- a/src/str.sh +++ b/src/str.sh @@ -2,6 +2,20 @@ _BASHUNIT_STR_STRIPPED_OUT="" +# Fork-free random alphanumeric string. Lives in this leaf module (not globals.sh) +# because env.sh calls it at source time to build the run-unique scratch paths: +# a helper used by the lowest layer must not sit above it. +function bashunit::random_str() { + local length=${1:-6} + local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + local str='' + local i + for ((i = 0; i < length; i++)); do + str="$str${chars:RANDOM%${#chars}:1}" + done + echo "$str" +} + # Strip ANSI escape codes and control characters, writing the result into the # global slot _BASHUNIT_STR_STRIPPED_OUT (no fork on the plain-text fast path). # Callers on hot paths (assert_equals/assert_not_equals) use this to avoid the diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 48810ddc..7c227ea8 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -62,8 +62,18 @@ function bashunit::spy() { export "_BASHUNIT_SPY_${variable}_TIMES_FILE"="$times_file" export "_BASHUNIT_SPY_${variable}_PARAMS_FILE"="$params_file" + # An all-digits second argument is an exit code; anything else non-empty is a + # replacement implementation. The `case` glob is the Bash 3.0 form of the old + # `[[ =~ ^[0-9]+$ ]]` (identical domain: a value is all-digits iff it is + # non-empty and contains no non-digit) and it keeps the interpolation below + # provably numeric, so `return $exit_code_or_impl` cannot inject shell syntax. local body_suffix="" - if [[ "$exit_code_or_impl" =~ ^[0-9]+$ ]]; then + local _is_exit_code=false + case "$exit_code_or_impl" in + '' | *[!0-9]*) ;; + *) _is_exit_code=true ;; + esac + if [ "$_is_exit_code" = true ]; then body_suffix="return $exit_code_or_impl" elif [ -n "$exit_code_or_impl" ]; then body_suffix="$exit_code_or_impl \"\$@\"" diff --git a/tests/acceptance/bashunit_hook_failure_test.sh b/tests/acceptance/bashunit_hook_failure_test.sh index 167625a3..2c4a10ce 100644 --- a/tests/acceptance/bashunit_hook_failure_test.sh +++ b/tests/acceptance/bashunit_hook_failure_test.sh @@ -41,3 +41,67 @@ function test_hook_failure_counts_match_in_parallel() { assert_contains "4 failed" "$output" assert_contains "5 total" "$output" } + +# Writes a throwaway test file whose `set_up` runs $1 (a failing assertion) and +# echoes its path. +# +# The file is assembled with printf, not a heredoc, on purpose: bashunit's own +# duplicate-test-function scan reads *this* file as text, and a literal +# `function test_...() {` line sitting inside a heredoc here would be counted as +# a test function defined twice in this file. +function _write_failing_hook_fixture() { + local hook_body="$1" + local name="$2" + local kw="function" + local dir + dir="$(bashunit::temp_dir)" + + { + printf '#!/usr/bin/env bash\n\n' + printf '%s set_up() {\n %s\n}\n\n' "$kw" "$hook_body" + printf '%s test_placeholder() {\n assert_same "1" "1"\n}\n' "$kw" + } >"$dir/$name" + + echo "$dir/$name" +} + +# An assertion that fails inside `set_up` runs with no `test_*` frame on the +# call stack, so its label falls back to `FUNCNAME[$fallback_depth]` -- the +# assertion function's own name. That makes the label sensitive to how many +# frames sit between the assertion and the label resolver, so any helper +# factored out of the assertion bodies has to compensate for the frame it adds. +# Nothing else pins this: the `-a` standalone path sets a custom title, which +# short-circuits the fallback entirely. +function test_assertion_failing_in_a_hook_is_labelled_with_its_own_name() { + local fixture + fixture="$(_write_failing_hook_fixture \ + 'assert_same "expected-from-hook" "actual-from-hook"' "label_fallback_test.sh")" + + local output + local exit_code=0 + output=$(./bashunit --no-parallel --detailed --no-color --skip-env-file \ + "$fixture" 2>&1) || exit_code=$? + + assert_general_error "" "" "$exit_code" + assert_contains "Failed: Assert same" "$output" + assert_contains "expected-from-hook" "$output" +} + +# Companion to the above for a multi-branch assertion: assert_arrays_equal used +# to resolve its label through the echoing `bashunit::assert::label` wrapper, +# whose extra frame made the fallback report the wrapper's own name +# ("Bashunit::assert::label") instead of the assertion's. +function test_array_assertion_failing_in_a_hook_is_labelled_with_its_own_name() { + local fixture + fixture="$(_write_failing_hook_fixture \ + 'assert_arrays_equal "a" -- "b"' "array_label_fallback_test.sh")" + + local output + local exit_code=0 + output=$(./bashunit --no-parallel --detailed --no-color --skip-env-file \ + "$fixture" 2>&1) || exit_code=$? + + assert_general_error "" "" "$exit_code" + assert_contains "Failed: Assert arrays equal" "$output" + assert_not_contains "Bashunit::assert::label" "$output" +} diff --git a/tests/acceptance/bashunit_skip_env_file_test.sh b/tests/acceptance/bashunit_skip_env_file_test.sh index 042426d5..107b3bd2 100644 --- a/tests/acceptance/bashunit_skip_env_file_test.sh +++ b/tests/acceptance/bashunit_skip_env_file_test.sh @@ -27,8 +27,7 @@ function test_skip_env_file_via_flag() { } function test_without_skip_env_file_loads_dotenv() { - # Without --skip-env-file, the .env should be loaded - # This test verifies normal behavior still works + # Without --skip-env-file, the .env is loaded (the default path). local output output=$(./bashunit --no-parallel --simple --env "$TEST_ENV_FILE" \ tests/acceptance/fixtures/test_bashunit_when_a_test_passes.sh 2>&1) || true diff --git a/tests/acceptance/bashunit_strict_mode_test.sh b/tests/acceptance/bashunit_strict_mode_test.sh index 64d8bf93..289ca748 100644 --- a/tests/acceptance/bashunit_strict_mode_test.sh +++ b/tests/acceptance/bashunit_strict_mode_test.sh @@ -44,6 +44,16 @@ function test_strict_mode_fails_on_unset_variable_in_set_up() { assert_contains "failed" "$output" } +function test_strict_mode_reports_an_omitted_variadic_actual_as_a_plain_failure() { + local output + output=$(BASHUNIT_STRICT_MODE=true ./bashunit --no-parallel --simple --skip-env-file --env "$TEST_ENV_FILE" \ + tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh 2>&1) || true + + assert_not_contains "unbound variable" "$output" + assert_contains "to contain" "$output" + assert_contains "to start with" "$output" +} + function test_cli_flag_overrides_env_var() { local output output=$(BASHUNIT_STRICT_MODE=false ./bashunit \ diff --git a/tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh b/tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh new file mode 100644 index 00000000..0a213021 --- /dev/null +++ b/tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# The "actual" operand of these assertions is variadic ("${@:2}"), so omitting +# it leaves an empty array. Expanding an empty array under `set -u` (--strict) +# is an unbound-variable error on Bash < 4.4, which used to abort the test with +# an internal error instead of reporting a normal assertion failure. +function test_contains_without_actual() { + assert_contains "needle" +} + +function test_string_starts_with_without_actual() { + assert_string_starts_with "prefix" +} diff --git a/tests/unit/assert_numeric_test.sh b/tests/unit/assert_numeric_test.sh index d8f7020d..ab9166b4 100644 --- a/tests/unit/assert_numeric_test.sh +++ b/tests/unit/assert_numeric_test.sh @@ -38,6 +38,43 @@ function test_unsuccessful_return_assert_exit_code() { assert_exit_code "1" "$(fake_function)" } +# `[ -eq ]`/`[ -ne ]` exit with status 2 (not 1) when an operand is not an +# integer. The exit-code assertions must read that as "did not match" and fail; +# reading it as "matched" would count a bogus assertion as passed. +function test_assert_exit_code_fails_when_the_expected_code_is_not_an_integer() { + local expected + expected="$(bashunit::console_results::print_failed_test \ + "Assert exit code fails when the expected code is not an integer" "0" "to be" "not-a-number")" + + assert_same "$expected" "$(assert_exit_code "not-a-number" "" "0" 2>/dev/null)" +} + +function test_assert_exit_code_fails_when_the_actual_code_is_not_an_integer() { + local expected + expected="$(bashunit::console_results::print_failed_test \ + "Assert exit code fails when the actual code is not an integer" "not-a-number" "to be" "0")" + + assert_same "$expected" "$(assert_exit_code "0" "" "not-a-number" 2>/dev/null)" +} + +function test_assert_unsuccessful_code_fails_when_the_actual_code_is_not_an_integer() { + local expected + expected="$(bashunit::console_results::print_failed_test \ + "Assert unsuccessful code fails when the actual code is not an integer" \ + "not-a-number" "to be non-zero" "but was 0")" + + assert_same "$expected" "$(assert_unsuccessful_code "" "" "not-a-number" 2>/dev/null)" +} + +function test_assert_successful_code_fails_when_the_actual_code_is_not_an_integer() { + local expected + expected="$(bashunit::console_results::print_failed_test \ + "Assert successful code fails when the actual code is not an integer" \ + "not-a-number" "to be exactly" "0")" + + assert_same "$expected" "$(assert_successful_code "" "" "not-a-number" 2>/dev/null)" +} + function test_successful_assert_successful_code() { function fake_function() { return 0 diff --git a/tests/unit/console_results_test.sh b/tests/unit/console_results_test.sh index 8d886eb9..b2cb08c1 100644 --- a/tests/unit/console_results_test.sh +++ b/tests/unit/console_results_test.sh @@ -363,7 +363,6 @@ function test_render_execution_time_on_osx_with_perl() { local render_result mock_macos - bashunit::mock bashunit::dependencies::has_adjtimex mock_false bashunit::mock bashunit::dependencies::has_perl mock_true _BASHUNIT_START_TIME="1726393394574382186" bashunit::mock perl <<<"1726393394574372186" diff --git a/tests/unit/coverage_helpers_test.sh b/tests/unit/coverage_helpers_test.sh index 2439a82e..10bd8bf4 100644 --- a/tests/unit/coverage_helpers_test.sh +++ b/tests/unit/coverage_helpers_test.sh @@ -247,20 +247,13 @@ EOF # === Line hits tests === -function test_coverage_get_line_hits_returns_zero_when_no_file() { - _BASHUNIT_COVERAGE_DATA_FILE="" - - local result - result=$(bashunit::coverage::get_line_hits "/path/to/file.sh" 10) - - assert_equals "0" "$result" -} - -function test_coverage_get_line_hits_counts_correctly() { +function test_coverage_get_all_line_hits_counts_per_line() { BASHUNIT_COVERAGE="true" bashunit::coverage::init - local test_file="/test/script.sh" + local test_file + test_file="$(bashunit::temp_file coverage_line_hits).sh" + printf 'echo one\necho two\necho three\necho four\necho five\n' >"$test_file" { echo "${test_file}:5" echo "${test_file}:5" @@ -268,7 +261,9 @@ function test_coverage_get_line_hits_counts_correctly() { } >>"$_BASHUNIT_COVERAGE_DATA_FILE" local result - result=$(bashunit::coverage::get_line_hits "$test_file" 5) + result=$(bashunit::coverage::get_all_line_hits "$test_file") + + rm -f "$test_file" - assert_equals "3" "$result" + assert_equals "5:3" "$result" } diff --git a/tests/unit/dependencies_test.sh b/tests/unit/dependencies_test.sh index 12ca6f58..8bd1f628 100644 --- a/tests/unit/dependencies_test.sh +++ b/tests/unit/dependencies_test.sh @@ -7,13 +7,6 @@ function test_has_perl_search_path_for_perl() { assert_have_been_called_with command "-v perl" } -function test_has_adjtimex() { - bashunit::spy command - bashunit::dependencies::has_adjtimex - - assert_have_been_called_with command "-v adjtimex" -} - function test_has_bc() { bashunit::spy command diff --git a/tests/unit/env_test.sh b/tests/unit/env_test.sh index 565e565c..670b2b14 100644 --- a/tests/unit/env_test.sh +++ b/tests/unit/env_test.sh @@ -462,3 +462,42 @@ function test_cleanup_run_output_dir_refuses_paths_outside_the_run_tree() { assert_same 1 "$status" assert_file_exists "$dir/keep" } + +# --- create_scratch_dirs ------------------------------------------------------ + +function test_create_scratch_dirs_creates_both_directories() { + local base + base="$(bashunit::temp_dir)" + + bashunit::env::create_scratch_dirs "$base/run/OSX/abc123" "$base/tmp" + + assert_directory_exists "$base/run/OSX/abc123" + assert_directory_exists "$base/tmp" +} + +function test_create_scratch_dirs_is_idempotent() { + local base + base="$(bashunit::temp_dir)" + bashunit::env::create_scratch_dirs "$base/run/OSX/abc123" "$base/tmp" + + local status=0 + bashunit::env::create_scratch_dirs "$base/run/OSX/abc123" "$base/tmp" || status=$? + + assert_same 0 "$status" +} + +function test_create_scratch_dirs_fails_loudly_when_a_directory_cannot_be_created() { + local base + base="$(bashunit::temp_dir)" + # A regular file in the middle of the path makes `mkdir -p` fail on every + # platform, without relying on permission bits (faked on Git Bash). + printf 'not a directory\n' >"$base/blocker" + + local status=0 + local output + output="$(bashunit::env::create_scratch_dirs "$base/blocker/run" "$base/tmp" 2>&1)" || status=$? + + assert_same 1 "$status" + assert_contains "cannot create the scratch directory" "$output" + assert_contains "$base/blocker/run" "$output" +} diff --git a/tests/unit/fixtures/release/mock_checksum b/tests/unit/fixtures/release/mock_checksum deleted file mode 100644 index fca74032..00000000 --- a/tests/unit/fixtures/release/mock_checksum +++ /dev/null @@ -1 +0,0 @@ -abc123def456 bin/bashunit diff --git a/tests/unit/fixtures/tests/example1_test.sh b/tests/unit/fixtures/tests/example1_test.sh index 48150bd2..e57f0527 100644 --- a/tests/unit/fixtures/tests/example1_test.sh +++ b/tests/unit/fixtures/tests/example1_test.sh @@ -1,2 +1,2 @@ #!/usr/bin/env bash -# internationally blank +# Intentionally empty: discovery tests only match this file by path. diff --git a/tests/unit/fixtures/tests/example2_test.sh b/tests/unit/fixtures/tests/example2_test.sh index 48150bd2..e57f0527 100644 --- a/tests/unit/fixtures/tests/example2_test.sh +++ b/tests/unit/fixtures/tests/example2_test.sh @@ -1,2 +1,2 @@ #!/usr/bin/env bash -# internationally blank +# Intentionally empty: discovery tests only match this file by path. diff --git a/tests/unit/fixtures/tests/example3_test.bash b/tests/unit/fixtures/tests/example3_test.bash index d60cdcdc..e57f0527 100644 --- a/tests/unit/fixtures/tests/example3_test.bash +++ b/tests/unit/fixtures/tests/example3_test.bash @@ -1,2 +1,2 @@ #!/usr/bin/env bash -# intentionally blank +# Intentionally empty: discovery tests only match this file by path. diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index ea3cd8fb..18219201 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -322,18 +322,6 @@ function test_build_provider_map_no_parallel_marker_defaults_false() { rm -f "$file" } -function test_left_trim() { - assert_same "foo" "$(bashunit::helper::trim " foo")" -} - -function test_right_trim() { - assert_same "foo" "$(bashunit::helper::trim "foo ")" -} - -function test_trim() { - assert_same "foo" "$(bashunit::helper::trim " foo ")" -} - function test_find_files_recursive_given_file() { local path path="$(bashunit::current_dir)/fixtures/tests/example1_test.sh" @@ -377,6 +365,32 @@ function test_find_files_recursive_given_bash_extension() { assert_same "tests/unit/fixtures/tests/example3_test.bash" "$result" } +function test_find_files_recursive_given_wildcard_in_a_path_with_spaces() { + local base + base=$(bashunit::temp_dir "spaced") + local dir="$base/a dir with spaces" + mkdir -p "$dir" + touch "$dir/first_test.sh" "$dir/second_test.sh" + + local result + result=$(bashunit::helper::find_files_recursive "$dir/*") + + assert_same "$dir/first_test.sh +$dir/second_test.sh" "$result" +} + +function test_find_files_recursive_given_wildcard_does_not_evaluate_the_path() { + local dir + dir=$(bashunit::temp_dir "unevaluated") + touch "$dir/marker_test.sh" + + # A `;`-bearing path must be treated as data, never as shell syntax. + local result + result=$(bashunit::helper::find_files_recursive "$dir/*;echo pwned" 2>/dev/null || true) + + assert_not_contains "pwned" "$result" +} + function test_get_latest_tag() { bashunit::mock git </dev/null + bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null echo "$_BASHUNIT_ASSERTIONS_PASSED" ) @@ -280,7 +280,7 @@ function test_aggregate_sets_failed_assertion_count() { local failed failed=$( - bashunit::parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null + bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null echo "$_BASHUNIT_ASSERTIONS_FAILED" ) @@ -293,7 +293,7 @@ function test_aggregate_sets_skipped_assertion_count() { local skipped skipped=$( - bashunit::parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null + bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null echo "$_BASHUNIT_ASSERTIONS_SKIPPED" ) @@ -306,7 +306,7 @@ function test_aggregate_sets_incomplete_assertion_count() { local incomplete incomplete=$( - bashunit::parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null + bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null echo "$_BASHUNIT_ASSERTIONS_INCOMPLETE" ) @@ -319,13 +319,39 @@ function test_aggregate_sets_snapshot_assertion_count() { local snapshot snapshot=$( - bashunit::parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null + bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null echo "$_BASHUNIT_ASSERTIONS_SNAPSHOT" ) assert_same "4" "$snapshot" } +# A .result file that never received the encoded payload leaves every ##KEY= +# strip a no-op, so the raw text would reach $(( )) and abort the aggregation +# with an arithmetic syntax error. It must degrade to zeros and count as failed. +function test_aggregate_treats_an_unparseable_result_file_as_a_failed_test() { + _create_result_file "$TEMP_DIR_PARALLEL_TEST_SUITE/script1" "test1.result" \ + "bash: line 3: syntax error near unexpected token" + + # get_tests_failed is cumulative for the whole run, so assert the delta this + # aggregation adds — an absolute value would also count any earlier failure. + local before + before=$(bashunit::state::get_tests_failed) + + local result passed failed tests_failed + result=$( + bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null + echo "$_BASHUNIT_ASSERTIONS_PASSED $_BASHUNIT_ASSERTIONS_FAILED $(bashunit::state::get_tests_failed)" + ) + IFS=' ' read -r passed failed tests_failed </dev/null + bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" >/dev/null echo "$_BASHUNIT_ASSERTIONS_PASSED $_BASHUNIT_ASSERTIONS_FAILED" ) IFS=' ' read -r passed failed <&1 + ) || status=$? + + # release.sh's EXIT_* constants are `declare -r` at its top level, which makes + # them function-local when release.sh is sourced from set_up_before_script. + assert_same 2 "$status" + assert_contains "Failed to copy the project into the sandbox" "$output" + + cd "$original_dir" || return +} + function test_sandbox_setup_git_initializes_repo() { if ! command -v git >/dev/null 2>&1; then bashunit::skip "git not available" && return diff --git a/tests/unit/release_utilities_test.sh b/tests/unit/release_utilities_test.sh index 1f391c32..2faeab18 100644 --- a/tests/unit/release_utilities_test.sh +++ b/tests/unit/release_utilities_test.sh @@ -176,6 +176,41 @@ function test_rollback_restore_files_restores_nested_paths() { rm -rf "$temp_dir" } +function test_rollback_auto_reports_success_when_files_are_restored() { + local temp_dir + temp_dir=$(mktemp -d) + + local output + output=$( + cd "$temp_dir" || return + echo "original content" >testfile.txt + release::backup::init + release::backup::save_file "testfile.txt" + echo "modified content" >testfile.txt + release::rollback::auto 2>&1 + ) + + assert_contains "Rollback complete" "$output" + assert_not_contains "Rollback FAILED" "$output" + rm -rf "$temp_dir" +} + +function test_rollback_auto_reports_failure_when_files_cannot_be_restored() { + local temp_dir + temp_dir=$(mktemp -d) + + local output + output=$( + cd "$temp_dir" || return + BACKUP_DIR="" + release::rollback::auto 2>&1 + ) + + assert_contains "Rollback FAILED" "$output" + assert_not_contains "Rollback complete" "$output" + rm -rf "$temp_dir" +} + ########################## # Pre-flight check tests ########################## diff --git a/tests/unit/reports_test.sh b/tests/unit/reports_test.sh index 2994f5cd..c62e978a 100644 --- a/tests/unit/reports_test.sh +++ b/tests/unit/reports_test.sh @@ -69,7 +69,7 @@ function _mock_state_functions() { function bashunit::clock::total_runtime_in_milliseconds() { echo "1234"; } } -# === Existing test === +# === No-report-output short circuit === function test_add_test_skips_tracking_without_report_output() { local before after diff --git a/tests/unit/runner_test.sh b/tests/unit/runner_test.sh index 391590c5..31ddf545 100644 --- a/tests/unit/runner_test.sh +++ b/tests/unit/runner_test.sh @@ -159,6 +159,20 @@ function test_compute_total_assertions_treats_missing_counters_as_zero() { assert_same "2" "$_BASHUNIT_RUNNER_TOTAL_OUT" } +# Without the digits guard the ##KEY= strips are no-ops on a non-payload result, +# so the raw text reaches $(( )) and raises a fatal arithmetic syntax error. +function test_compute_total_assertions_treats_a_non_payload_result_as_zero() { + bashunit::runner::compute_total_assertions "bash: line 3: syntax error near unexpected token" + + assert_same "0" "$_BASHUNIT_RUNNER_TOTAL_OUT" +} + +function test_compute_total_assertions_treats_a_non_numeric_counter_as_zero() { + bashunit::runner::compute_total_assertions "##ASSERTIONS_PASSED=2##ASSERTIONS_FAILED=oops" + + assert_same "0" "$_BASHUNIT_RUNNER_TOTAL_OUT" +} + # Builds a one-line encoded test result like execute_test_body emits. # Args: failed passed skipped incomplete snapshot exit_code function build_encoded_result() { @@ -336,3 +350,81 @@ function test_functions_for_script_preserves_caller_extdebug_state() { assert_same "on" "$state" } + +# --- result-line layout contract --------------------------------------------- +# The "##KEY=value##" subshell result record has two writers: +# bashunit::state::export_subshell_context (canonical) and the timeout-path copy +# bashunit::runner::build_timeout_result. They live in different files, so a +# field added to one silently shortens the record the other emits, and every +# reader (runner::extract_result_counts, state::aggregate_parallel_results) +# then mis-parses timed-out tests. Folding the two writers into one function +# would put a call on the per-test hot path that #762/#764 deliberately +# stripped; pinning the layout costs nothing at runtime and fails the moment +# the copies diverge. + +# Echoes the ordered field keys of a "##KEY=value##..." record, one per line. +function result_line_field_keys() { + local rest="$1" + local key + while [ -n "$rest" ]; do + case "$rest" in + *"##"*) rest="${rest#*##}" ;; + *) break ;; + esac + key="${rest%%=*}" + case "$key" in + "$rest") continue ;; + esac + printf '%s\n' "$key" + done +} + +function test_timeout_result_uses_the_same_field_layout_as_the_state_writer() { + local canonical + canonical="$(bashunit::state::export_subshell_context)" + local timeout_line + timeout_line="$(bashunit::runner::build_timeout_result)" + + assert_same \ + "$(result_line_field_keys "$canonical")" \ + "$(result_line_field_keys "$timeout_line")" +} + +function test_timeout_result_reports_the_conventional_timed_out_exit_code() { + bashunit::runner::extract_result_counts "$(bashunit::runner::build_timeout_result)" + + assert_same "124" "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" + assert_same "0" "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" + assert_same "0" "$_BASHUNIT_RUNNER_COUNTS_PASSED_OUT" +} + +# --- restore_workdir ---------------------------------------------------------- + +function test_restore_workdir_returns_to_the_given_directory() { + local target + target="$(bashunit::temp_dir)" + + local landed + landed=$( + cd / || exit 1 + bashunit::runner::restore_workdir "$target" + pwd -P + ) + + assert_same "$(cd "$target" && pwd -P)" "$landed" +} + +function test_restore_workdir_aborts_loudly_when_the_directory_is_gone() { + local gone + gone="$(bashunit::temp_dir)/removed" + + local status=0 + local output + # `$(...)` is already a subshell, so the function's `exit 1` ends the capture + # rather than the test. + output="$(bashunit::runner::restore_workdir "$gone" 2>&1)" || status=$? + + assert_same 1 "$status" + assert_contains "cannot restore the working directory" "$output" + assert_contains "$gone" "$output" +} diff --git a/tests/unit/state_test.sh b/tests/unit/state_test.sh index bc08af4a..54b17e5d 100644 --- a/tests/unit/state_test.sh +++ b/tests/unit/state_test.sh @@ -320,18 +320,6 @@ function test_decode_base64_returns_empty_for_empty_value() { assert_same "" "$(bashunit::helper::decode_base64 "")" } -function test_calculate_total_assertions() { - local input="##ASSERTIONS_FAILED=1\ - ##ASSERTIONS_PASSED=2\ - ##ASSERTIONS_SKIPPED=3\ - ##ASSERTIONS_INCOMPLETE=4\ - ##ASSERTIONS_SNAPSHOT=5\ - ##TEST_EXIT_CODE=0\ - ##TEST_OUTPUT=3zhbEncodedBase64##" - - assert_same 15 "$(bashunit::state::calculate_total_assertions "$input")" -} - # --- print_tap_line ----------------------------------------------------------- # Each capture runs in $(...) so mutating _BASHUNIT_TOTAL_TESTS_COUNT never # leaks into the suite's own counters.