From 4f247313c7a6a7a4846fca7b6dbc7153767a2c23 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 21:49:57 +0200 Subject: [PATCH 01/18] ref(console): reuse one duration formatter instead of an inline copy print_successful_test carried a verbatim 14-line copy of format_duration. Extract format_duration_to_slot following the house return-slot idiom so the per-test path formats fork-free, and keep format_duration as a thin echoing wrapper for the cold call sites. Output verified identical over the full ms range (all three branches). --- src/console_results.sh | 47 +++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 23 deletions(-) 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" From 8ebcbd3450a955071127285d113f5ebd530afef7 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 21:57:52 +0200 Subject: [PATCH 02/18] docs: remove work-in-motion narration and fix stale branch-kind comment Comment-only cleanup. The substantive fix is in the extract_branches header, which still documented kind as {if, case} after loop constructs became branch points (#858); the code emits |loop| and the unit tests already spec three kinds, so the header was the stale side. Also documents two previously undocumented load-bearing constructs: the readonly re-source guard on the result-parsing regex, and the embed markers build::embed_docs aborts on. Drops comments that only record when something was written ("Existing test", "had no tests at all", "First unit tests for"), and fixes an "internationally blank" typo in three discovery fixtures. No behavior change; no shellcheck directive removed. --- src/coverage.sh | 5 +++-- src/doc.sh | 7 ++++--- src/main.sh | 2 +- src/runner.sh | 4 +++- tests/acceptance/bashunit_skip_env_file_test.sh | 3 +-- tests/unit/fixtures/tests/example1_test.sh | 2 +- tests/unit/fixtures/tests/example2_test.sh | 2 +- tests/unit/fixtures/tests/example3_test.bash | 2 +- tests/unit/learn_test.sh | 10 +++++----- tests/unit/main_test.sh | 6 +++--- tests/unit/reports_test.sh | 2 +- 11 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/coverage.sh b/src/coverage.sh index 7c3b6c27..24af7236 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -876,8 +876,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/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/main.sh b/src/main.sh index cf0c5b24..b4b3a391 100644 --- a/src/main.sh +++ b/src/main.sh @@ -1072,7 +1072,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/runner.sh b/src/runner.sh index 6e103a6b..7179f142 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # shellcheck disable=SC2155 -# Pre-compiled regex pattern for parsing test result assertions +# Regex matching the encoded ##KEY=value## counters of a per-test result line. +# Guarded on "already set" because the readonly would abort a re-source of this +# file (the acceptance suite sources bashunit into an already-loaded shell). 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]*)##'\ 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/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/learn_test.sh b/tests/unit/learn_test.sh index ac14b5ac..064cd87b 100644 --- a/tests/unit/learn_test.sh +++ b/tests/unit/learn_test.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash set -euo pipefail -# src/learn.sh had no tests at all. These cover its non-interactive core: the -# progress persistence and the environment lifecycle. LEARN_PROGRESS_FILE is a -# readonly resolved from $HOME at source time, so each test sources learn.sh in -# a fresh shell with HOME pointed at an isolated directory — the suite's own -# already-sourced copy (bound to the real $HOME) is never exercised. +# Covers the non-interactive core of src/learn.sh: progress persistence and the +# environment lifecycle. LEARN_PROGRESS_FILE is a readonly resolved from $HOME at +# source time, so each test sources learn.sh in a fresh shell with HOME pointed +# at an isolated directory — the suite's own already-sourced copy (bound to the +# real $HOME) is never exercised. # Runs a snippet against a freshly-sourced learn.sh inside an isolated HOME/CWD. function _learn_in_sandbox() { diff --git a/tests/unit/main_test.sh b/tests/unit/main_test.sh index 482f4ba1..8c98019b 100644 --- a/tests/unit/main_test.sh +++ b/tests/unit/main_test.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -# First unit tests for src/main.sh: pin set_shard_or_exit's validation, which -# guards --shard's slice arithmetic against off-by-one specs. It exits the -# shell on invalid input, so every call runs inside a subshell. +# Pins set_shard_or_exit's validation, which guards --shard's slice arithmetic +# against off-by-one specs. It exits the shell on invalid input, so every call +# runs inside a subshell. function _shard_status() { ( 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 From 61864505f236286b183d7530964056be73a49c9c Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 21:59:55 +0200 Subject: [PATCH 03/18] fix(runner): fail loudly on internal errors instead of swallowing them Four places where bashunit's own plumbing (not user test code) hid a failure and carried on with a silently wrong answer: - env.sh: the run scratch dir and shared temp dir were created with `mkdir -p ... 2>/dev/null || true`. Every deferred-output collector (failures, skipped, incomplete, risky, profile, rerun) is created lazily by `>>` under that dir and read behind `[ -s ]`, so a failed mkdir turned all of them into no-ops: a red suite rendered as green on a read-only or full TMPDIR. Extracted bashunit::env::create_scratch_dirs, which asserts the postcondition (both dirs exist) rather than trusting mkdir's exit code, and aborts with an actionable message. Checking the postcondition keeps a benign mkdir -p race on the shared temp dir harmless. - runner.sh: restore_workdir swallowed a failed `cd` back to BASHUNIT_WORKING_DIR. Since every later test file is discovered and sourced relative to that directory, the loop kept going from wherever the user's set_up_before_script left it and quietly dropped the remaining files from the run. It now aborts with a clear error, and takes an optional target so it is testable. - release.sh: rollback::auto printed "Rollback complete. Files restored to pre-release state." even when restore_files had failed, sending the operator away from a half-released working tree. Now reports the failure. Also asserts that the sandbox actually received a copy of the project: neither the tar pipe nor the cp fallback gives a conclusive exit code, so a rehearsal could "pass" every step it never ran. Kept as load-bearing (unchanged): `grep -c || true` under errexit, `((var++)) || true`, optional-tool probes, cross-platform fallbacks, and the set +euo pipefail / trap - ERR hook-failure contract from #836. --- CHANGELOG.md | 5 ++++ release.sh | 25 ++++++++++++++++-- src/env.sh | 35 +++++++++++++++++++++++-- src/runner.sh | 20 +++++++++++++- tests/unit/env_test.sh | 39 ++++++++++++++++++++++++++++ tests/unit/release_sandbox_test.sh | 23 ++++++++++++++++ tests/unit/release_utilities_test.sh | 35 +++++++++++++++++++++++++ tests/unit/runner_test.sh | 31 ++++++++++++++++++++++ 8 files changed, 208 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd411ecf..16d09eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### 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) +### Fixed +- bashunit now aborts with an actionable error when its scratch directories under `TMPDIR` cannot be created, instead of continuing with every failure/skip collector silently writing nowhere — a red suite could previously render as green on a read-only or full `TMPDIR` +- 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 + ## [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/env.sh b/src/env.sh index 387a8665..da0454eb 100644 --- a/src/env.sh +++ b/src/env.sh @@ -508,8 +508,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/runner.sh b/src/runner.sh index 6e103a6b..4e3ef57d 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -8,8 +8,26 @@ if [ -z "${_BASHUNIT_RUNNER_PARSE_RESULT_REGEX+x}" ]; then '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 } ## 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/release_sandbox_test.sh b/tests/unit/release_sandbox_test.sh index 709313a5..4fde81bd 100644 --- a/tests/unit/release_sandbox_test.sh +++ b/tests/unit/release_sandbox_test.sh @@ -94,6 +94,29 @@ function test_sandbox_create_excludes_release_state() { cd "$original_dir" || return } +function test_sandbox_create_aborts_when_no_copy_method_produced_a_usable_copy() { + local original_dir + original_dir=$(pwd) + + local status=0 + local output + output=$( + cd "$FIXTURE_DIR" || exit 0 + # Force both copy methods to fail: the sandbox then stays empty, which used + # to go unnoticed until a later step rehearsed against nothing. + bashunit::mock tar false + bashunit::mock cp false + release::sandbox::create 2>&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/runner_test.sh b/tests/unit/runner_test.sh index 391590c5..5a535f55 100644 --- a/tests/unit/runner_test.sh +++ b/tests/unit/runner_test.sh @@ -336,3 +336,34 @@ function test_functions_for_script_preserves_caller_extdebug_state() { assert_same "on" "$state" } + +# --- 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" +} From 68a2753977db521d49ffa1a3448bfcf75452791a Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 22:06:32 +0200 Subject: [PATCH 04/18] ref(env): consolidate shared data contracts into single sources of truth Bash has no type system; the equivalent shared contracts are the global env-var table, the named constants, and the delimited records passed between processes. Three of them had more than one definition site. - runner: drop _BASHUNIT_RUNNER_PARSE_RESULT_REGEX. It is a second, already-divergent statement of the "##KEY=value##" result-record layout (it stops at TEST_EXIT_CODE and never gained the four trailing fields), left behind by #610 when the [[ =~ ]] parse was replaced with parameter expansion. It has zero consumers, so a future reader would "update the contract" there and change nothing. - runner: pin build_timeout_result to state::export_subshell_context. The record has two writers in two files; a field added to the canonical one silently shortens the timeout line and every reader then mis-parses timed-out tests. Folding them into one function would put a call on the per-test hot path that #762/#764 deliberately stripped, so the layout is pinned by a test instead: zero runtime cost, fails on divergence. - env: register BASHUNIT_RERUN_FAILED, BASHUNIT_COVERAGE_SHOW_LINE_HITS and BASHUNIT_PARALLEL_JOBS in the central defaults table. They were the only public variables whose default lived inline at the read site (rerun.sh, coverage.sh) or as a bare literal. Purely additive: the :- guards stay so callers that unset them still work, and no bare aliases are introduced, matching the reasoning already recorded for RETRY/SEED. - .env.example: BASHUNIT_SHOW_EXECUTION_TIME has defaulted to "auto" since #765, not "true". Adds the 15 supported variables the file was missing. --- .env.example | 17 +++++++++++++- CHANGELOG.md | 3 +++ src/env.sh | 16 ++++++++++++- src/runner.sh | 7 ------ tests/unit/runner_test.sh | 47 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 0c2adbf4..0947725a 100644 --- a/.env.example +++ b/.env.example @@ -19,25 +19,37 @@ 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_SHOW_OUTPUT_ON_FAILURE= # Default: true (print the test's output when it fails) BASHUNIT_FAILURES_ONLY= # Default: false (only show failures) BASHUNIT_FAIL_ON_RISKY= # Default: false (treat no-assertion tests as failed) BASHUNIT_PROFILE= # Default: false (report slowest tests after a run) BASHUNIT_PROFILE_COUNT= # Default: 10 (how many slowest tests to report) +BASHUNIT_NO_PROGRESS= # Default: false (hide the per-test progress output) BASHUNIT_NO_COLOR= # Default: false (disable colors) BASHUNIT_NO_DIFF= # Default: false (disable unified diff on multiline assert failures) +BASHUNIT_OUTPUT_FORMAT= # Default: empty (set to "tap" for TAP output on stdout) #─────────────────────────────────────────────────────────────────────────────── # Test Execution #─────────────────────────────────────────────────────────────────────────────── BASHUNIT_PARALLEL_RUN= # Default: false +BASHUNIT_PARALLEL_JOBS= # Default: 0 (max parallel workers; 0 = unbounded) BASHUNIT_STOP_ON_FAILURE= # Default: false (stop suite on first failure) BASHUNIT_RERUN_FAILED= # Default: false (replay only last run's failing tests) BASHUNIT_STOP_ON_ASSERTION_FAILURE= # Default: true (stop test on first assertion fail) BASHUNIT_STRICT_MODE= # Default: false (enable set -euo pipefail) BASHUNIT_LOGIN_SHELL= # Default: false (source login shell profiles) +BASHUNIT_SKIP_ENV_FILE= # Default: false (do not load .env / .bashunitrc) +BASHUNIT_TEST_TIMEOUT= # Default: 0 (per-test timeout in seconds; 0 = disabled) +BASHUNIT_RETRY= # Default: 0 (extra attempts for a failed test) +BASHUNIT_RANDOM_ORDER= # Default: false (randomize test execution order) +BASHUNIT_SEED= # Default: empty (seed for BASHUNIT_RANDOM_ORDER) +BASHUNIT_SHARD_INDEX= # Default: empty (this runner's shard, 1-based) +BASHUNIT_SHARD_TOTAL= # Default: empty (how many shards the suite is split into) +BASHUNIT_WATCH_INTERVAL= # Default: 2 (watch-mode poll interval in seconds) #─────────────────────────────────────────────────────────────────────────────── # Reports @@ -45,6 +57,8 @@ BASHUNIT_LOGIN_SHELL= # Default: false (source login shell profile BASHUNIT_LOG_JUNIT= # JUnit XML report path (e.g., report.xml) BASHUNIT_LOG_GHA= # GitHub Actions workflow-commands log path (e.g., gha.log) BASHUNIT_REPORT_HTML= # HTML test report path (e.g., report.html) +BASHUNIT_REPORT_TAP= # TAP report path (e.g., report.tap) +BASHUNIT_REPORT_JSON= # JSON test report path (e.g., report.json) #─────────────────────────────────────────────────────────────────────────────── # Code Coverage @@ -57,6 +71,7 @@ BASHUNIT_COVERAGE_REPORT_HTML= # HTML coverage report directory (e.g., cove BASHUNIT_COVERAGE_MIN= # Minimum coverage % (fails if below) BASHUNIT_COVERAGE_THRESHOLD_LOW= # Default: 50 (red below this) BASHUNIT_COVERAGE_THRESHOLD_HIGH= # Default: 80 (green above this) +BASHUNIT_COVERAGE_SHOW_LINE_HITS= # Default: false (add a per-line hit-count block to the text report) #─────────────────────────────────────────────────────────────────────────────── # Advanced / Debug diff --git a/CHANGELOG.md b/CHANGELOG.md index bd411ecf..19105a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### 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) +### Fixed +- `.env.example` documented `BASHUNIT_SHOW_EXECUTION_TIME` as defaulting to `true`; the actual default has been `auto` since #765. It now also lists the 15 supported variables it was missing (`BASHUNIT_REPORT_TAP`, `BASHUNIT_REPORT_JSON`, `BASHUNIT_OUTPUT_FORMAT`, `BASHUNIT_NO_PROGRESS`, `BASHUNIT_SHOW_OUTPUT_ON_FAILURE`, `BASHUNIT_PARALLEL_JOBS`, `BASHUNIT_SKIP_ENV_FILE`, `BASHUNIT_TEST_TIMEOUT`, `BASHUNIT_RETRY`, `BASHUNIT_RANDOM_ORDER`, `BASHUNIT_SEED`, `BASHUNIT_SHARD_INDEX`, `BASHUNIT_SHARD_TOTAL`, `BASHUNIT_WATCH_INTERVAL`, `BASHUNIT_COVERAGE_SHOW_LINE_HITS`). No behaviour change + ## [0.43.0](https://github.com/TypedDevs/bashunit/compare/0.42.0...0.43.0) - 2026-07-24 ### Added diff --git a/src/env.sh b/src/env.sh index 387a8665..67ee3c10 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" diff --git a/src/runner.sh b/src/runner.sh index 6e103a6b..1739e366 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -1,13 +1,6 @@ #!/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 - function bashunit::runner::restore_workdir() { cd "$BASHUNIT_WORKING_DIR" 2>/dev/null || true } diff --git a/tests/unit/runner_test.sh b/tests/unit/runner_test.sh index 391590c5..aa339736 100644 --- a/tests/unit/runner_test.sh +++ b/tests/unit/runner_test.sh @@ -336,3 +336,50 @@ 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, parallel::aggregate_test_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" +} From 2ec94a5a834e7eb3baeca84e840d829c8ec8cb4c Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 22:10:04 +0200 Subject: [PATCH 05/18] ref: remove superseded internal helpers and an unreachable runner branch The 2026 performance campaign left behind five internal helpers with no production callers, plus one branch that can no longer execute: - runner::call_test_functions kept a pre-cache fallback that recomputed the function list. Both call sites pass the cached list, and load_test_files skips a file whose list is empty, so the branch was unreachable since #598. Its filter/tag arguments were only read there, so the signature drops them. - state::calculate_total_assertions: superseded by runner::compute_total_assertions (#598), which does the same sum fork-free. - coverage::get_line_hits: superseded by coverage::get_all_line_hits (#644), which also propagates hits across backslash-continuation chains. Its two tests are ported to the live function rather than deleted. - helper::trim: never called from src/ since it was added in 2023. - dependencies::has_adjtimex: dead from birth, the adjtimex clock branch it was written for never shipped. Also: env::find_terminal_width guarded its first probe with a condition that is always true (leftover from the 2024 env.sh unification), and the --parallel unsupported-OS warning still claimed Alpine was excluded even though parallel runs there have been supported since the race conditions were fixed. All removals are sub-namespaced internal helpers, outside the documented public API (assert_* plus single-level bashunit::). --- CHANGELOG.md | 6 +++ src/coverage.sh | 14 ------- src/dependencies.sh | 4 -- src/env.sh | 2 +- src/helpers.sh | 10 ----- src/main.sh | 5 ++- src/runner.sh | 64 ++++++++--------------------- src/state.sh | 15 ------- tests/unit/console_results_test.sh | 1 - tests/unit/coverage_helpers_test.sh | 21 ++++------ tests/unit/dependencies_test.sh | 7 ---- tests/unit/helpers_test.sh | 12 ------ tests/unit/state_test.sh | 12 ------ 13 files changed, 34 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd411ecf..5a3109f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ### 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 + +### 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/src/coverage.sh b/src/coverage.sh index 7c3b6c27..07c4d888 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" 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/env.sh b/src/env.sh index 387a8665..66a0506b 100644 --- a/src/env.sh +++ b/src/env.sh @@ -419,7 +419,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 diff --git a/src/helpers.sh b/src/helpers.sh index 555a5463..efa18617 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -501,16 +501,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..a430ca49 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 diff --git a/src/runner.sh b/src/runner.sh index 6e103a6b..5777e9cd 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -502,13 +502,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" @@ -775,54 +771,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 diff --git a/src/state.sh b/src/state.sh index c36b74ff..4fa2056f 100644 --- a/src/state.sh +++ b/src/state.sh @@ -255,21 +255,6 @@ function bashunit::state::export_subshell_context() { printf '%s\n' "$payload" } -function bashunit::state::calculate_total_assertions() { - local input="$1" - local total=0 - - local numbers - numbers=$(echo "$input" | grep -oE '##ASSERTIONS_\w+=[0-9]+' | grep -oE '[0-9]+') - - local number - for number in $numbers; do - total=$((total + number)) - done - - echo $total -} - function bashunit::state::print_line() { # shellcheck disable=SC2034 local type=$1 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/helpers_test.sh b/tests/unit/helpers_test.sh index ea3cd8fb..06aff3e2 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" 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. From 8312bbbc09d980290673c685d6ffa66a24aa0df3 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 22:10:18 +0200 Subject: [PATCH 06/18] ref(src): break the env/globals and parallel/state call cycles The src/ call graph had two 2-node strongly connected components. Both were design coupling rather than runtime bugs (every module is sourced before the run starts, and build.sh concatenates them into one file, so Bash resolves the mutual calls fine) - but each hid a helper living in the wrong module. env.sh <-> globals.sh: env.sh owns BASHUNIT_DEV_LOG and its predicates yet had to call up into globals.sh to log, while globals.sh's log writers called back down for those predicates. env.sh also called bashunit::random_str at source time, which only worked because the entrypoint happens to source globals.sh first - an undocumented ordering constraint. - bashunit::random_str -> str.sh (pure-bash, zero deps, genuine leaf) - bashunit::current_timestamp, bashunit::log, bashunit::internal_log -> env.sh, next to is_dev_mode_enabled / is_internal_log_enabled parallel.sh <-> state.sh: parallel::aggregate_test_results hand-parsed the '##ASSERTIONS_FAILED=...##TEST_EXIT_CODE=...' payload that state::export_subshell_context writes, so the decoder for state's own format lived in another module and had to reach up for all six counters. - renamed to bashunit::state::aggregate_parallel_results and moved to state.sh, next to the encoder; body unchanged No behaviour, signature or public API change - all four functions keep their names and stay available from the same entrypoint. The call graph is now an acyclic 12-layer DAG. --- src/env.sh | 44 +++++++++++++ src/globals.sh | 47 ------------- src/parallel.sh | 116 -------------------------------- src/runner.sh | 2 +- src/state.sh | 127 ++++++++++++++++++++++++++++++++++++ src/str.sh | 14 ++++ tests/unit/parallel_test.sh | 16 ++--- 7 files changed, 194 insertions(+), 172 deletions(-) diff --git a/src/env.sh b/src/env.sh index 387a8665..835878ab 100644 --- a/src/env.sh +++ b/src/env.sh @@ -298,6 +298,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" ] } 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/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..c8bbbf80 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -524,7 +524,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 diff --git a/src/state.sh b/src/state.sh index c36b74ff..df72d491 100644 --- a/src/state.sh +++ b/src/state.sh @@ -255,6 +255,133 @@ function bashunit::state::export_subshell_context() { printf '%s\n' "$payload" } +## +# 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} + + # 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::state::calculate_total_assertions() { local input="$1" local total=0 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/tests/unit/parallel_test.sh b/tests/unit/parallel_test.sh index 64b37917..2269781d 100644 --- a/tests/unit/parallel_test.sh +++ b/tests/unit/parallel_test.sh @@ -240,7 +240,7 @@ function test_must_stop_on_failure_returns_false_when_no_file() { assert_general_error "$(bashunit::parallel::must_stop_on_failure)" } -# === aggregate_test_results tests === +# === aggregate_parallel_results tests === function _create_result_file() { local dir="$1" @@ -255,7 +255,7 @@ function test_aggregate_handles_no_result_files() { mkdir -p "$TEMP_DIR_PARALLEL_TEST_SUITE/script1" local output - output=$(bashunit::parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE") + output=$(bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE") assert_contains "No tests found" "$output" } @@ -267,7 +267,7 @@ function test_aggregate_sets_passed_assertion_count() { # Run in subshell to isolate state changes local passed passed=$( - 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_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,7 +319,7 @@ 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" ) @@ -334,7 +334,7 @@ function test_aggregate_sums_multiple_result_files() { local result passed failed result=$( - 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_PASSED $_BASHUNIT_ASSERTIONS_FAILED" ) IFS=' ' read -r passed failed < Date: Fri, 24 Jul 2026 22:11:46 +0200 Subject: [PATCH 07/18] ref: remove dead helpers and an unreferenced fixture bashunit::dependencies::has_adjtimex had no production caller: the whole has_* family was cross-checked and every other member is used, while the clock.sh time-source chain (perl > python > node > powershell > date) has no adjtimex step. Its last real use died in ab451be. bashunit::coverage::get_line_hits ran one grep -c per line and was superseded by get_all_line_hits in 6afbded; all seven production call sites use the single-pass variant. Both survived only on tests that assert the function does the one thing it does, with no other caller in the tree. Those tautological tests go with them, plus a now-inert has_adjtimex mock in console_results_test. tests/unit/fixtures/release/mock_checksum is referenced by no tracked file; test_update_checksum_updates_package_json writes its own. --- src/coverage.sh | 14 ------------ src/dependencies.sh | 4 ---- tests/unit/console_results_test.sh | 1 - tests/unit/coverage_helpers_test.sh | 28 ----------------------- tests/unit/dependencies_test.sh | 7 ------ tests/unit/fixtures/release/mock_checksum | 1 - 6 files changed, 55 deletions(-) delete mode 100644 tests/unit/fixtures/release/mock_checksum diff --git a/src/coverage.sh b/src/coverage.sh index 7c3b6c27..07c4d888 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" 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/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..f13d27cf 100644 --- a/tests/unit/coverage_helpers_test.sh +++ b/tests/unit/coverage_helpers_test.sh @@ -244,31 +244,3 @@ EOF rm -f "$temp_file" } - -# === 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() { - BASHUNIT_COVERAGE="true" - bashunit::coverage::init - - local test_file="/test/script.sh" - { - echo "${test_file}:5" - echo "${test_file}:5" - echo "${test_file}:5" - } >>"$_BASHUNIT_COVERAGE_DATA_FILE" - - local result - result=$(bashunit::coverage::get_line_hits "$test_file" 5) - - assert_equals "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/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 From fd982ecdb3cbb4a3c09f9b68eb4547bcaa93a4d7 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 21:51:39 +0200 Subject: [PATCH 08/18] test(assert): pin the assertion-label fallback for non-test frames When an assertion fails with no test_* frame on the stack and no custom title, the label is derived from FUNCNAME[$fallback_depth] -- the assertion's own name. That makes it sensitive to stack depth, and nothing covered it: the -a standalone path sets a custom title, which short-circuits the fallback. The fixtures are assembled with printf rather than a heredoc so bashunit's own duplicate-test-function scan does not read them as tests defined twice here. --- .../acceptance/bashunit_hook_failure_test.sh | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) 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" +} From caa58bcdc8df88c5c8339dc0506ca17b011f3f9c Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 22:13:03 +0200 Subject: [PATCH 09/18] ref(assert): collapse the repeated failure-reporting block into fail_with Every assertion repeated the same four lines to report a failure: resolve the label into a slot, copy it to a local, mark the assertion failed, then call print_failed_test. That block appeared 57 times across eight files. bashunit::assert::fail_with does all four. It passes an explicit fallback depth of 3 to label_to_slot so the extra stack frame is invisible: when no test_* frame is on the stack the label still resolves to the assertion, not to the helper. label_to_slot grew an optional depth argument for that; its default is unchanged, so every other caller behaves exactly as before. assert_array_contains, assert_array_length, assert_array_not_contains and assert_file_permissions also stop resolving a label on their success paths, where it was computed and discarded. --- src/assert.sh | 186 ++++++++++++++--------------------------- src/assert_arrays.sh | 33 ++------ src/assert_dates.sh | 25 ++---- src/assert_duration.sh | 15 +--- src/assert_files.sh | 56 +++---------- src/assert_folders.sh | 45 ++-------- src/assert_json.sh | 20 +---- src/bashunit.sh | 5 +- 8 files changed, 104 insertions(+), 281 deletions(-) diff --git a/src/assert.sh b/src/assert.sh index a893a28d..c3082fbc 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -15,19 +15,47 @@ _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. @@ -171,10 +199,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 +219,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 +239,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 +253,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 +267,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 +282,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 @@ -293,10 +303,7 @@ function assert_contains() { 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 +328,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 @@ -345,10 +349,7 @@ function assert_not_contains() { 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 @@ -395,10 +396,7 @@ function assert_not_matches() { # 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 +555,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 @@ -575,10 +570,7 @@ 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}" + bashunit::assert::fail_with "${label_override:-}" "${actual_exit_code}" "to be" "${expected_exit_code}" return fi @@ -593,11 +585,8 @@ 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}" + bashunit::assert::fail_with "${label_override:-}" \ + "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi @@ -610,10 +599,7 @@ function assert_unsuccessful_code() { 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" + bashunit::assert::fail_with "${label_override:-}" "${actual_exit_code}" "to be non-zero" "but was 0" return fi @@ -628,11 +614,8 @@ 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}" + bashunit::assert::fail_with "${label_override:-}" \ + "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi @@ -647,11 +630,8 @@ 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}" + bashunit::assert::fail_with "${label_override:-}" \ + "${actual_exit_code}" "to be exactly" "${expected_exit_code}" return fi @@ -672,10 +652,7 @@ function assert_string_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 start with" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to start with" "${expected}" return ;; esac @@ -692,10 +669,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 @@ -717,10 +691,7 @@ function assert_string_ends_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 end with" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to end with" "${expected}" return ;; esac @@ -741,10 +712,7 @@ function assert_string_not_ends_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 end with" "${expected}" + bashunit::assert::fail_with "${label_override:-}" "${actual}" "to not end with" "${expected}" return ;; esac @@ -760,10 +728,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 +743,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 +758,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 +773,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 +811,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 +823,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 +860,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 +922,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 +940,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}" } From 27e87e761ca42123eec7d480ce4107f11e5d4898 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 22:13:09 +0200 Subject: [PATCH 10/18] fix(assert): report assert_arrays_equal failures with the assertion's name assert_arrays_equal resolved its label through the echoing bashunit::assert::label wrapper. That wrapper adds a stack frame, so when the assertion failed outside a test function (e.g. from a set_up hook) the fallback named the wrapper: 'Bashunit::assert::label'. It now reports 'Assert arrays equal' like every other assertion, and no longer forks a subshell to build the label. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd411ecf..3659a4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### 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) +### Fixed +- `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 + ## [0.43.0](https://github.com/TypedDevs/bashunit/compare/0.42.0...0.43.0) - 2026-07-24 ### Added From 246be12292387d206aa263d5b35a209a4bd10eed Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 22:17:45 +0200 Subject: [PATCH 11/18] fix(helpers): expand glob paths without eval find_files_recursive built a find command string and eval'd it whenever the path contained a literal '*'. eval word-splits the already-interpolated string, so a quoted glob over a directory with a space in its name ('my dir/*') became the two roots 'my' and 'dir/*' and matched nothing. It also expanded $-substitutions and ran ';'-separated commands present in a path. Expand the glob into an array with IFS='' instead: that disables field splitting while leaving pathname expansion intact (verified on Bash 3.2), so the roots survive spaces and the path is never evaluated as shell syntax. A non-matching glob still stays literal, preserving the previous behaviour of handing the raw pattern to find. --- src/helpers.sh | 16 ++++++++++++++-- tests/unit/helpers_test.sh | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/helpers.sh b/src/helpers.sh index 555a5463..2ec4d447 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 diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index ea3cd8fb..f3b1e2b4 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -377,6 +377,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 < Date: Fri, 24 Jul 2026 22:18:26 +0200 Subject: [PATCH 12/18] fix(assert): fail closed when an exit-code operand is not an integer [ x -ne y ] exits with status 2, not 1, when an operand is not an integer. The exit-code assertions tested 'if [ actual -ne expected ]' to decide the failure branch, so status 2 was read as 'condition false' and fell through to add_assertions_passed: assert_exit_code 'not-a-number' was counted as a PASSED assertion. The test only went red incidentally, because the runner treats the [ builtin's stderr as an execution error; in 'bashunit assert' mode the verdict was simply wrong. State the pass condition inside [ ] and negate it. For two integers this is exactly equivalent to the old form; for an unparseable operand status 2 negates to true, so the assertion fails closed. This is the '! [ -lt ]' shape assert_less_than and friends already use, which is why that family was never affected. Applies to assert_exit_code, assert_successful_code, assert_unsuccessful_code, assert_general_error and assert_command_not_found. --- src/assert.sh | 18 ++++++++++----- tests/unit/assert_numeric_test.sh | 37 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/assert.sh b/src/assert.sh index a893a28d..1de6ac3c 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -574,7 +574,11 @@ function assert_exit_code() { local expected_exit_code="$1" - if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then + # 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::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed @@ -592,7 +596,8 @@ function assert_successful_code() { local expected_exit_code=0 - if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then + # Negated pass condition: see assert_exit_code. + if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed @@ -609,7 +614,8 @@ function assert_unsuccessful_code() { local label_override="" bashunit::assert::should_skip && return 0 - if [ "$actual_exit_code" -eq 0 ]; then + # Negated pass condition: see assert_exit_code. + if ! [ "$actual_exit_code" -ne 0 ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed @@ -627,7 +633,8 @@ function assert_general_error() { local expected_exit_code=1 - if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then + # Negated pass condition: see assert_exit_code. + if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed @@ -646,7 +653,8 @@ function assert_command_not_found() { local expected_exit_code=127 - if [ "$actual_exit_code" -ne "$expected_exit_code" ]; then + # Negated pass condition: see assert_exit_code. + if ! [ "$actual_exit_code" -eq "$expected_exit_code" ]; then bashunit::assert::label_to_slot "${label_override:-}" local label=$_BASHUNIT_ASSERT_LABEL_OUT bashunit::assert::mark_failed 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 From af3c132ce96aa2cb4aa8a7df37680422be583634 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 22:18:42 +0200 Subject: [PATCH 13/18] fix(assert): guard empty variadic actual under set -u on bash 3.x Seven assertions take a variadic actual via actual_arr=("${@:2}"), so calling one with the actual omitted leaves the array empty. Expanding an empty array with a bare "${arr[@]}" is an unbound-variable error under set -u on Bash < 4.4, and set -u is live inside the test subshell whenever --strict is on. The same call therefore aborted with an internal error on macOS system bash (3.2) while reporting a clean assertion failure on Bash 5. Use the "${arr[@]+"${arr[@]}"}" guard that assert_line_count in this same file already uses, so an omitted actual joins to "" on every supported Bash. Covers assert_contains, assert_not_contains, assert_matches, assert_not_matches, assert_string_starts_with, assert_string_ends_with and assert_string_not_ends_with. The new acceptance test is only non-vacuous on Bash < 4.4; it guards the Bash 3.0 CI leg. --- src/assert.sh | 19 ++++++++++++------- tests/acceptance/bashunit_strict_mode_test.sh | 10 ++++++++++ .../strict_mode_variadic_actual_omitted.sh | 13 +++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh diff --git a/src/assert.sh b/src/assert.sh index 1de6ac3c..ee0ed8a6 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -33,6 +33,11 @@ _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="$*" @@ -287,7 +292,7 @@ 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 @@ -340,7 +345,7 @@ 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 @@ -363,7 +368,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,7 +394,7 @@ 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 @@ -674,7 +679,7 @@ 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 @@ -719,7 +724,7 @@ 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 @@ -744,7 +749,7 @@ 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 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" +} From 3e2af143c56ebdc24f800294d42cb560f029ee9e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 22:18:51 +0200 Subject: [PATCH 14/18] fix(runner): treat an unparseable result payload as zero, not a crash compute_total_assertions and the parallel aggregator extract counters with ${result##*##ASSERTIONS_FAILED=}. When the marker is absent the strip is a no-op and the variable holds the entire input string. The existing ${x:-0} guard only defends against empty, not against non-numeric, and arbitrary text in $(( )) is not 0 -- it is a fatal arithmetic syntax error that aborts the run. In the parallel path the same text also reached [ "$exit_code" -ne 0 ], which exits 2 and was therefore read as 'equal to 0', counting an unreadable result file as a passing test. Validate with a single case over the concatenated fields: digits (or empty, which $(( )) already reads as 0) pass through untouched, anything else degrades to zeros. One case per test, no fork, so the per-test fork budget is unchanged. An unparseable result additionally forces exit_code=1 so it counts as failed rather than silently passing, and is logged. --- src/parallel.sh | 14 ++++++++++++++ src/runner.sh | 12 ++++++++++-- tests/unit/parallel_test.sh | 21 +++++++++++++++++++++ tests/unit/runner_test.sh | 14 ++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/parallel.sh b/src/parallel.sh index 9ea33529..eb7bd769 100755 --- a/src/parallel.sh +++ b/src/parallel.sh @@ -55,6 +55,20 @@ function bashunit::parallel::aggregate_test_results() { 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_test_results" "unparseable result file:$result_file" + ;; + esac + # Add to the total counts total_failed=$((total_failed + failed)) total_passed=$((total_passed + passed)) diff --git a/src/runner.sh b/src/runner.sh index 6e103a6b..d526f4b5 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -178,9 +178,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 } diff --git a/tests/unit/parallel_test.sh b/tests/unit/parallel_test.sh index 64b37917..3787142e 100644 --- a/tests/unit/parallel_test.sh +++ b/tests/unit/parallel_test.sh @@ -326,6 +326,27 @@ function test_aggregate_sets_snapshot_assertion_count() { 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" + + local result passed failed tests_failed + result=$( + bashunit::parallel::aggregate_test_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 < Date: Fri, 24 Jul 2026 22:18:57 +0200 Subject: [PATCH 15/18] ref(doubles): replace [[ =~ ]] with a bash 3.0 case glob The only [[ ]] left in src/. It is prohibited by the project's Bash 3.0+ rules, and the codebase notes elsewhere that [[ =~ ]] is not available on the supported floor. A case glob is provably the same domain: a string is all-digits iff it is non-empty and contains no non-digit character. It also makes the eval'd 'return $exit_code_or_impl' interpolation provably numeric, so a spy exit code can no longer carry shell syntax into the generated function body. No behaviour change; covered by the existing spy tests. --- src/test_doubles.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 \"\$@\"" From 1852e2b5c03ff2b19364d74e66064c9993ba2632 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 24 Jul 2026 22:20:44 +0200 Subject: [PATCH 16/18] ref(runner): name the repeated stop-on-failure branch The same seven-line nest appeared three times in run_test: check the flag, then either raise the parallel stop file or exit with EXIT_CODE_STOP_ON_FAILURE. Extracted as halt_if_stop_on_failure, which flattens the three deepest branches in the file and gives the policy a name. exit inside a function still exits the same shell, so behaviour is unchanged. --- src/runner.sh | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/runner.sh b/src/runner.sh index 6e103a6b..f812a815 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -217,6 +217,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). @@ -1331,13 +1348,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 +1365,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 +1406,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 From c46e61d87027c17d5bf007e0c72995cf1c1c7ecc Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Jul 2026 06:16:05 +0200 Subject: [PATCH 17/18] docs: update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc45466b..b4121836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - 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 From ec6b1296851ffac8c8ffd13a477b996d2609e3a1 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 25 Jul 2026 06:36:29 +0200 Subject: [PATCH 18/18] fix(env): stop .env.example from clobbering caller-set variables Listing a variable in .env.example makes it an unconditional assignment once copied to .env, because .env is sourced under set -o allexport. Documenting BASHUNIT_OUTPUT_FORMAT and BASHUNIT_SHOW_OUTPUT_ON_FAILURE there therefore overrode values the caller had exported, breaking their env-var forms. Also assert the tests-failed delta rather than the cumulative total in the parallel aggregation test, which otherwise counts unrelated earlier failures. --- .env.example | 22 +++++++--------------- CHANGELOG.md | 2 +- tests/unit/parallel_test.sh | 7 ++++++- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 0947725a..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. ################################################################################ #─────────────────────────────────────────────────────────────────────────────── @@ -22,34 +29,22 @@ BASHUNIT_NO_OUTPUT= # Default: false (suppress all output) 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_SHOW_OUTPUT_ON_FAILURE= # Default: true (print the test's output when it fails) BASHUNIT_FAILURES_ONLY= # Default: false (only show failures) BASHUNIT_FAIL_ON_RISKY= # Default: false (treat no-assertion tests as failed) BASHUNIT_PROFILE= # Default: false (report slowest tests after a run) BASHUNIT_PROFILE_COUNT= # Default: 10 (how many slowest tests to report) -BASHUNIT_NO_PROGRESS= # Default: false (hide the per-test progress output) BASHUNIT_NO_COLOR= # Default: false (disable colors) BASHUNIT_NO_DIFF= # Default: false (disable unified diff on multiline assert failures) -BASHUNIT_OUTPUT_FORMAT= # Default: empty (set to "tap" for TAP output on stdout) #─────────────────────────────────────────────────────────────────────────────── # Test Execution #─────────────────────────────────────────────────────────────────────────────── BASHUNIT_PARALLEL_RUN= # Default: false -BASHUNIT_PARALLEL_JOBS= # Default: 0 (max parallel workers; 0 = unbounded) BASHUNIT_STOP_ON_FAILURE= # Default: false (stop suite on first failure) BASHUNIT_RERUN_FAILED= # Default: false (replay only last run's failing tests) BASHUNIT_STOP_ON_ASSERTION_FAILURE= # Default: true (stop test on first assertion fail) BASHUNIT_STRICT_MODE= # Default: false (enable set -euo pipefail) BASHUNIT_LOGIN_SHELL= # Default: false (source login shell profiles) -BASHUNIT_SKIP_ENV_FILE= # Default: false (do not load .env / .bashunitrc) -BASHUNIT_TEST_TIMEOUT= # Default: 0 (per-test timeout in seconds; 0 = disabled) -BASHUNIT_RETRY= # Default: 0 (extra attempts for a failed test) -BASHUNIT_RANDOM_ORDER= # Default: false (randomize test execution order) -BASHUNIT_SEED= # Default: empty (seed for BASHUNIT_RANDOM_ORDER) -BASHUNIT_SHARD_INDEX= # Default: empty (this runner's shard, 1-based) -BASHUNIT_SHARD_TOTAL= # Default: empty (how many shards the suite is split into) -BASHUNIT_WATCH_INTERVAL= # Default: 2 (watch-mode poll interval in seconds) #─────────────────────────────────────────────────────────────────────────────── # Reports @@ -57,8 +52,6 @@ BASHUNIT_WATCH_INTERVAL= # Default: 2 (watch-mode poll interval in se BASHUNIT_LOG_JUNIT= # JUnit XML report path (e.g., report.xml) BASHUNIT_LOG_GHA= # GitHub Actions workflow-commands log path (e.g., gha.log) BASHUNIT_REPORT_HTML= # HTML test report path (e.g., report.html) -BASHUNIT_REPORT_TAP= # TAP report path (e.g., report.tap) -BASHUNIT_REPORT_JSON= # JSON test report path (e.g., report.json) #─────────────────────────────────────────────────────────────────────────────── # Code Coverage @@ -71,7 +64,6 @@ BASHUNIT_COVERAGE_REPORT_HTML= # HTML coverage report directory (e.g., cove BASHUNIT_COVERAGE_MIN= # Minimum coverage % (fails if below) BASHUNIT_COVERAGE_THRESHOLD_LOW= # Default: 50 (red below this) BASHUNIT_COVERAGE_THRESHOLD_HIGH= # Default: 80 (green above this) -BASHUNIT_COVERAGE_SHOW_LINE_HITS= # Default: false (add a per-line hit-count block to the text report) #─────────────────────────────────────────────────────────────────────────────── # Advanced / Debug diff --git a/CHANGELOG.md b/CHANGELOG.md index b4121836..5c97e808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - 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. It now also lists the 15 supported variables it was missing (`BASHUNIT_REPORT_TAP`, `BASHUNIT_REPORT_JSON`, `BASHUNIT_OUTPUT_FORMAT`, `BASHUNIT_NO_PROGRESS`, `BASHUNIT_SHOW_OUTPUT_ON_FAILURE`, `BASHUNIT_PARALLEL_JOBS`, `BASHUNIT_SKIP_ENV_FILE`, `BASHUNIT_TEST_TIMEOUT`, `BASHUNIT_RETRY`, `BASHUNIT_RANDOM_ORDER`, `BASHUNIT_SEED`, `BASHUNIT_SHARD_INDEX`, `BASHUNIT_SHARD_TOTAL`, `BASHUNIT_WATCH_INTERVAL`, `BASHUNIT_COVERAGE_SHOW_LINE_HITS`). No behaviour change +- `.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 diff --git a/tests/unit/parallel_test.sh b/tests/unit/parallel_test.sh index 268622b9..d46505a4 100644 --- a/tests/unit/parallel_test.sh +++ b/tests/unit/parallel_test.sh @@ -333,6 +333,11 @@ 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 @@ -344,7 +349,7 @@ EOF assert_same "0" "$passed" assert_same "0" "$failed" - assert_same "1" "$tests_failed" + assert_same "1" "$((tests_failed - before))" } function test_aggregate_sums_multiple_result_files() {