2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@
- 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
- `BASHUNIT_COVERAGE_THRESHOLD_LOW`/`BASHUNIT_COVERAGE_THRESHOLD_HIGH` (env-only, no CLI flag) now validate as non-negative integers like the other numeric settings. They are compared with `[ -ge ]` in the coverage class lookup, which errors instead of returning false on a non-integer value: a bad threshold leaked a raw `integer expression expected` into the coverage report and silently mis-bucketed every file's high/medium/low class
- `@max_ms` benchmark annotations with a decimal value (e.g. `@max_ms=100.5`) no longer leak a raw `integer expression expected` into the results table and always render as failing (`>`) regardless of the actual average; the threshold comparison now tolerates fractional operands like the average itself already could
- `bashunit assert <name>` with no arguments now reports a clear error and exits non-zero. It used to compute a `-1` array index, which Bash 3.x rejects, so it leaked a raw `bad array subscript` and still exited `0` β€” a malformed standalone assertion in a deploy script reported success (#877)
- A missing `--env`/`--boot` bootstrap file is now an error instead of a green run that tested nothing: the file used to be sourced unchecked, so a typo'd path leaked a raw `No such file or directory`, skipped the entire suite and still exited `0`. The report options (`--report-json`, `--log-junit`, `--report-tap`, `--report-html`, `--log-gha`) are likewise checked before the run instead of failing silently after it β€” an unwritable destination produced a passing run, no report on disk, and exit `0`. `--seed` now validates as a non-negative integer like the other numeric options (#875)
- The numeric options (`--jobs`, `--retry`, `--test-timeout`, `--coverage-min`) and `--output` now reject an invalid value with a clear error and a non-zero exit, instead of dropping the setting. `--jobs abc` was the worst case: `[ n -lt abc ]` errors rather than returning false, so the Bash 3.x job-slot poll never reached its `break` and the run **hung**, while Bash 4.3+ silently ignored the concurrency limit. `--coverage-min abc` leaked a raw `[: abc: integer expression expected` and still exited `0`, so a CI job never enforced its threshold. Validation runs on the resolved configuration, so it covers the `BASHUNIT_*` environment variables too (#873)
Expand Down
2 changes: 1 addition & 1 deletion src/benchmark.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,7 +143,7 @@ function bashunit::benchmark::print_results() {
continue
fi

if [ "$avg" -le "$max_ms" ]; then
if bashunit::math::is_le "$avg" "$max_ms"; then
local raw="≀ ${max_ms}"
local padded
padded=$(printf "%14s" "$raw")
Expand Down
99 changes: 46 additions & 53 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,9 +156,9 @@ function bashunit::coverage::get_tracked_files() {
# Get coverage class (high/medium/low) based on percentage
function bashunit::coverage::get_coverage_class() {
local pct="$1"
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" ]; then
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
echo "high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" ]; then
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
echo "medium"
else
echo "low"
Expand DownExpand Up@@ -610,11 +610,7 @@ function bashunit::coverage::get_hit_lines() {
function bashunit::coverage::compute_file_coverage() {
local file="$1"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local executable=0 hit=0 lineno=0 line line_hits
local -a cv_lines=()
Expand All@@ -628,7 +624,7 @@ function bashunit::coverage::compute_file_coverage() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
line_hits=${hits_by_line[lineno]:-0}
line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}
[ "$line_hits" -gt 0 ] && ((++hit))
done

Expand DownExpand Up@@ -715,6 +711,26 @@ function bashunit::coverage::get_all_line_hits() {
return 0
}

# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by
# line number -> hit count) from get_all_line_hits for $1.
#
# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)"
# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an
# array from a function or pass one by reference, and copying a sparse array
# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would
# corrupt the line-number keys -- so this loader writes one shared global
# instead (return-slot pattern, see bash-style.md). Callers must consume the
# result before the next call; there is no adjacent-call isolation.
declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE
function bashunit::coverage::load_hits_by_line() {
local file="$1"
_BASHUNIT_COVERAGE_HITS_BY_LINE=()
local hl_lineno hl_count
while IFS=: read -r hl_lineno hl_count; do
[ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count
done < <(bashunit::coverage::get_all_line_hits "$file")
}

# Get all test hits for a file in one pass (performance optimization)
# Output format: lineno|test_file:test_function (may have duplicates, one per hit)
function bashunit::coverage::get_all_line_tests() {
Expand DownExpand Up@@ -1012,17 +1028,18 @@ function bashunit::coverage::extract_branches() {
}

# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in
# [arm_start..arm_end] has a recorded hit, else 0. Caller must have
# populated the hits_by_line and src_lines arrays in scope; Bash 3.0
# cannot pass arrays into a function. Result is returned via the
# global to avoid a per-arm subshell.
# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from
# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see
# load_hits_by_line); caller must have populated the src_lines array in
# scope -- Bash 3.0 cannot pass arrays into a function. Result is
# returned via the global to avoid a per-arm subshell.
_BASHUNIT_ARM_TAKEN_OUT=0
function bashunit::coverage::_arm_taken() {
local arm_start="$1" arm_end="$2" ln
for ((ln = arm_start; ln <= arm_end; ln++)); do
bashunit::coverage::is_executable_line \
"${src_lines[$((ln - 1))]:-}" "$ln" || continue
if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then
_BASHUNIT_ARM_TAKEN_OUT=1
return
fi
Expand All@@ -1039,11 +1056,7 @@ function bashunit::coverage::_arm_taken() {
function bashunit::coverage::compute_branch_hits() {
local file="$1"

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a src_lines=()
local _sli=0 _sl
Expand DownExpand Up@@ -1218,19 +1231,15 @@ function bashunit::coverage::report_text_uncovered() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a uncovered_lines=()
local _ucount=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -eq 0 ]; then
uncovered_lines[_ucount]="$lineno"
_ucount=$((_ucount + 1))
Expand DownExpand Up@@ -1266,19 +1275,15 @@ function bashunit::coverage::report_text_line_hits() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a hit_specs=()
local _hc=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -gt 0 ]; then
hit_specs[_hc]="${lineno}:${lh}"
_hc=$((_hc + 1))
Expand DownExpand Up@@ -1311,11 +1316,7 @@ function bashunit::coverage::report_text_functions() {
functions_data=$(bashunit::coverage::extract_functions "$file")
[ -z "$functions_data" ] && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a file_lines=()
local _fli=0 _fl
Expand DownExpand Up@@ -1345,7 +1346,7 @@ function bashunit::coverage::report_text_functions() {
bashunit::coverage::is_executable_line \
"${file_lines[$((ln - 1))]:-}" "$ln" || continue
fn_executable=$((fn_executable + 1))
[ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
Expand DownExpand Up@@ -1377,11 +1378,7 @@ function bashunit::coverage::report_lcov() {

echo "SF:$file"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk
# and buffer the matching FNDA lines for emission after, per
Expand All@@ -1396,7 +1393,7 @@ function bashunit::coverage::report_lcov() {

any_hit=0
for ((fln = fn_start; fln <= fn_end; fln++)); do
if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then
any_hit=1
break
fi
Expand DownExpand Up@@ -1436,7 +1433,7 @@ function bashunit::coverage::report_lcov() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done
Expand DownExpand Up@@ -1824,19 +1821,19 @@ EOF
<div class="legend-item">
<span class="legend-color high"></span>
EOF
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High</span>"
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color medium"></span>
EOF
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium</span>"
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color low"></span>
EOF
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low</span>"
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -1921,11 +1918,7 @@ function bashunit::coverage::generate_file_html() {
local uncovered=$((executable - hit))

# Pre-load all line hits into indexed array (performance optimization)
local -a hits_by_line=()
local _ln _cnt
while IFS=: read -r _ln _cnt; do
hits_by_line[_ln]=$_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
Expand DownExpand Up@@ -2206,7 +2199,7 @@ EOF
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}
if [ "$ln_hits" -gt 0 ]; then
((++fn_hit))
fi
Expand DownExpand Up@@ -2269,7 +2262,7 @@ EOF

if bashunit::coverage::is_executable_line "$line" "$lineno"; then
# O(1) lookup from pre-loaded array
local hits=${hits_by_line[$lineno]:-0}
local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}

if [ "$hits" -gt 0 ]; then
row_class="covered"
Expand Down
21 changes: 8 additions & 13 deletions src/main.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,14 @@ function bashunit::main::validate_config_or_exit() {
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_MIN}" "BASHUNIT_COVERAGE_MIN (--coverage-min)"
fi
# Env-only (no CLI flag). Compared with `[ -ge ]` in
# bashunit::coverage::get_coverage_class, which errors instead of returning
# false on a non-integer operand, leaking a raw shell error into the
# coverage report and silently mis-bucketing every file's class (#879).
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" "BASHUNIT_COVERAGE_THRESHOLD_LOW"
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" "BASHUNIT_COVERAGE_THRESHOLD_HIGH"

if [ -n "${BASHUNIT_SEED:-}" ]; then
bashunit::main::require_non_negative_int_or_exit "${BASHUNIT_SEED}" "BASHUNIT_SEED (--seed)"
Expand DownExpand Up@@ -1168,7 +1176,6 @@ function bashunit::main::exec_assert() {

local assert_fn=$original_assert_fn

# Check if the function exists
if ! type "$assert_fn" >/dev/null 2>&1; then
assert_fn="assert_$assert_fn"
if ! type "$assert_fn" >/dev/null 2>&1; then
Expand All@@ -1187,14 +1194,12 @@ function bashunit::main::exec_assert() {
exit 1
fi

# Get the last argument safely by calculating the array length
local last_index=$((args_count - 1))
local last_arg="${args[$last_index]}"
local output=""
local inner_exit_code=0
local bashunit_exit_code=0

# Handle different assert_* functions
case "$assert_fn" in
assert_exit_code)
output=$(bashunit::main::handle_assert_exit_code "$last_arg")
Expand All@@ -1213,10 +1218,8 @@ function bashunit::main::exec_assert() {
assert_fn="assert_same"
fi

# Set a friendly test title for CLI assert command output
bashunit::state::set_test_title "assert ${original_assert_fn#assert_}"

# Run the assertion function and write into stderr
"$assert_fn" "${args[@]}" 1>&2
bashunit_exit_code=$?

Expand DownExpand Up@@ -1258,34 +1261,29 @@ function bashunit::main::exec_multi_assert() {
local cmd="$1"
shift

# Require at least one assertion
if [ $# -lt 1 ]; then
printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
printf "Usage: bashunit assert \"<command>\" <assertion1> <arg1> [<assertion2> <arg2>...]\n" 1>&2
return 1
fi

# Check that assertions come in pairs (assertion + arg)
if [ $# -lt 2 ] || [ $(($# % 2)) -ne 0 ]; then
local assertion_name="${1:-}"
printf "%sError: Missing argument for assertion '%s'.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
return 1
fi

# Execute command and capture output + exit code
local stdout
local cmd_exit_code
stdout=$(eval "$cmd" 2>&1)
cmd_exit_code=$?

# Print stdout for user visibility
if [ -n "$stdout" ]; then
echo "$stdout" 1>&1
fi

# Parse and execute assertions in pairs
local overall_result=0
while [ $# -gt 0 ]; do
local assertion_name="$1"
Expand All@@ -1299,7 +1297,6 @@ function bashunit::main::exec_multi_assert() {

shift 2

# Resolve assertion function name
local assert_fn="$assertion_name"
if ! type "$assert_fn" &>/dev/null; then
assert_fn="assert_$assertion_name"
Expand All@@ -1310,10 +1307,8 @@ function bashunit::main::exec_multi_assert() {
fi
fi

# Set test title for this assertion
bashunit::state::set_test_title "assert ${assertion_name#assert_}"

# Execute assertion with appropriate argument
if bashunit::main::is_exit_code_assertion "$assertion_name"; then
# Exit code assertion: pass expected value and captured exit code
"$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@
- 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
- `BASHUNIT_COVERAGE_THRESHOLD_LOW`/`BASHUNIT_COVERAGE_THRESHOLD_HIGH` (env-only, no CLI flag) now validate as non-negative integers like the other numeric settings. They are compared with `[ -ge ]` in the coverage class lookup, which errors instead of returning false on a non-integer value: a bad threshold leaked a raw `integer expression expected` into the coverage report and silently mis-bucketed every file's high/medium/low class
- `@max_ms` benchmark annotations with a decimal value (e.g. `@max_ms=100.5`) no longer leak a raw `integer expression expected` into the results table and always render as failing (`>`) regardless of the actual average; the threshold comparison now tolerates fractional operands like the average itself already could
- `bashunit assert <name>` with no arguments now reports a clear error and exits non-zero. It used to compute a `-1` array index, which Bash 3.x rejects, so it leaked a raw `bad array subscript` and still exited `0` β€” a malformed standalone assertion in a deploy script reported success (#877)
- A missing `--env`/`--boot` bootstrap file is now an error instead of a green run that tested nothing: the file used to be sourced unchecked, so a typo'd path leaked a raw `No such file or directory`, skipped the entire suite and still exited `0`. The report options (`--report-json`, `--log-junit`, `--report-tap`, `--report-html`, `--log-gha`) are likewise checked before the run instead of failing silently after it β€” an unwritable destination produced a passing run, no report on disk, and exit `0`. `--seed` now validates as a non-negative integer like the other numeric options (#875)
- The numeric options (`--jobs`, `--retry`, `--test-timeout`, `--coverage-min`) and `--output` now reject an invalid value with a clear error and a non-zero exit, instead of dropping the setting. `--jobs abc` was the worst case: `[ n -lt abc ]` errors rather than returning false, so the Bash 3.x job-slot poll never reached its `break` and the run **hung**, while Bash 4.3+ silently ignored the concurrency limit. `--coverage-min abc` leaked a raw `[: abc: integer expression expected` and still exited `0`, so a CI job never enforced its threshold. Validation runs on the resolved configuration, so it covers the `BASHUNIT_*` environment variables too (#873)
Expand Down
2 changes: 1 addition & 1 deletion src/benchmark.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,7 +143,7 @@ function bashunit::benchmark::print_results() {
continue
fi

if [ "$avg" -le "$max_ms" ]; then
if bashunit::math::is_le "$avg" "$max_ms"; then
local raw="≀ ${max_ms}"
local padded
padded=$(printf "%14s" "$raw")
Expand Down
99 changes: 46 additions & 53 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,9 +156,9 @@ function bashunit::coverage::get_tracked_files() {
# Get coverage class (high/medium/low) based on percentage
function bashunit::coverage::get_coverage_class() {
local pct="$1"
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" ]; then
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
echo "high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" ]; then
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
echo "medium"
else
echo "low"
Expand DownExpand Up@@ -610,11 +610,7 @@ function bashunit::coverage::get_hit_lines() {
function bashunit::coverage::compute_file_coverage() {
local file="$1"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local executable=0 hit=0 lineno=0 line line_hits
local -a cv_lines=()
Expand All@@ -628,7 +624,7 @@ function bashunit::coverage::compute_file_coverage() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
line_hits=${hits_by_line[lineno]:-0}
line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}
[ "$line_hits" -gt 0 ] && ((++hit))
done

Expand DownExpand Up@@ -715,6 +711,26 @@ function bashunit::coverage::get_all_line_hits() {
return 0
}

# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by
# line number -> hit count) from get_all_line_hits for $1.
#
# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)"
# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an
# array from a function or pass one by reference, and copying a sparse array
# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would
# corrupt the line-number keys -- so this loader writes one shared global
# instead (return-slot pattern, see bash-style.md). Callers must consume the
# result before the next call; there is no adjacent-call isolation.
declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE
function bashunit::coverage::load_hits_by_line() {
local file="$1"
_BASHUNIT_COVERAGE_HITS_BY_LINE=()
local hl_lineno hl_count
while IFS=: read -r hl_lineno hl_count; do
[ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count
done < <(bashunit::coverage::get_all_line_hits "$file")
}

# Get all test hits for a file in one pass (performance optimization)
# Output format: lineno|test_file:test_function (may have duplicates, one per hit)
function bashunit::coverage::get_all_line_tests() {
Expand DownExpand Up@@ -1012,17 +1028,18 @@ function bashunit::coverage::extract_branches() {
}

# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in
# [arm_start..arm_end] has a recorded hit, else 0. Caller must have
# populated the hits_by_line and src_lines arrays in scope; Bash 3.0
# cannot pass arrays into a function. Result is returned via the
# global to avoid a per-arm subshell.
# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from
# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see
# load_hits_by_line); caller must have populated the src_lines array in
# scope -- Bash 3.0 cannot pass arrays into a function. Result is
# returned via the global to avoid a per-arm subshell.
_BASHUNIT_ARM_TAKEN_OUT=0
function bashunit::coverage::_arm_taken() {
local arm_start="$1" arm_end="$2" ln
for ((ln = arm_start; ln <= arm_end; ln++)); do
bashunit::coverage::is_executable_line \
"${src_lines[$((ln - 1))]:-}" "$ln" || continue
if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then
_BASHUNIT_ARM_TAKEN_OUT=1
return
fi
Expand All@@ -1039,11 +1056,7 @@ function bashunit::coverage::_arm_taken() {
function bashunit::coverage::compute_branch_hits() {
local file="$1"

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a src_lines=()
local _sli=0 _sl
Expand DownExpand Up@@ -1218,19 +1231,15 @@ function bashunit::coverage::report_text_uncovered() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a uncovered_lines=()
local _ucount=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -eq 0 ]; then
uncovered_lines[_ucount]="$lineno"
_ucount=$((_ucount + 1))
Expand DownExpand Up@@ -1266,19 +1275,15 @@ function bashunit::coverage::report_text_line_hits() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a hit_specs=()
local _hc=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -gt 0 ]; then
hit_specs[_hc]="${lineno}:${lh}"
_hc=$((_hc + 1))
Expand DownExpand Up@@ -1311,11 +1316,7 @@ function bashunit::coverage::report_text_functions() {
functions_data=$(bashunit::coverage::extract_functions "$file")
[ -z "$functions_data" ] && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a file_lines=()
local _fli=0 _fl
Expand DownExpand Up@@ -1345,7 +1346,7 @@ function bashunit::coverage::report_text_functions() {
bashunit::coverage::is_executable_line \
"${file_lines[$((ln - 1))]:-}" "$ln" || continue
fn_executable=$((fn_executable + 1))
[ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
Expand DownExpand Up@@ -1377,11 +1378,7 @@ function bashunit::coverage::report_lcov() {

echo "SF:$file"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk
# and buffer the matching FNDA lines for emission after, per
Expand All@@ -1396,7 +1393,7 @@ function bashunit::coverage::report_lcov() {

any_hit=0
for ((fln = fn_start; fln <= fn_end; fln++)); do
if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then
any_hit=1
break
fi
Expand DownExpand Up@@ -1436,7 +1433,7 @@ function bashunit::coverage::report_lcov() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done
Expand DownExpand Up@@ -1824,19 +1821,19 @@ EOF
<div class="legend-item">
<span class="legend-color high"></span>
EOF
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High</span>"
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color medium"></span>
EOF
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium</span>"
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color low"></span>
EOF
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low</span>"
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -1921,11 +1918,7 @@ function bashunit::coverage::generate_file_html() {
local uncovered=$((executable - hit))

# Pre-load all line hits into indexed array (performance optimization)
local -a hits_by_line=()
local _ln _cnt
while IFS=: read -r _ln _cnt; do
hits_by_line[_ln]=$_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
Expand DownExpand Up@@ -2206,7 +2199,7 @@ EOF
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}
if [ "$ln_hits" -gt 0 ]; then
((++fn_hit))
fi
Expand DownExpand Up@@ -2269,7 +2262,7 @@ EOF

if bashunit::coverage::is_executable_line "$line" "$lineno"; then
# O(1) lookup from pre-loaded array
local hits=${hits_by_line[$lineno]:-0}
local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}

if [ "$hits" -gt 0 ]; then
row_class="covered"
Expand Down
21 changes: 8 additions & 13 deletions src/main.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,14 @@ function bashunit::main::validate_config_or_exit() {
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_MIN}" "BASHUNIT_COVERAGE_MIN (--coverage-min)"
fi
# Env-only (no CLI flag). Compared with `[ -ge ]` in
# bashunit::coverage::get_coverage_class, which errors instead of returning
# false on a non-integer operand, leaking a raw shell error into the
# coverage report and silently mis-bucketing every file's class (#879).
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" "BASHUNIT_COVERAGE_THRESHOLD_LOW"
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" "BASHUNIT_COVERAGE_THRESHOLD_HIGH"

if [ -n "${BASHUNIT_SEED:-}" ]; then
bashunit::main::require_non_negative_int_or_exit "${BASHUNIT_SEED}" "BASHUNIT_SEED (--seed)"
Expand DownExpand Up@@ -1168,7 +1176,6 @@ function bashunit::main::exec_assert() {

local assert_fn=$original_assert_fn

# Check if the function exists
if ! type "$assert_fn" >/dev/null 2>&1; then
assert_fn="assert_$assert_fn"
if ! type "$assert_fn" >/dev/null 2>&1; then
Expand All@@ -1187,14 +1194,12 @@ function bashunit::main::exec_assert() {
exit 1
fi

# Get the last argument safely by calculating the array length
local last_index=$((args_count - 1))
local last_arg="${args[$last_index]}"
local output=""
local inner_exit_code=0
local bashunit_exit_code=0

# Handle different assert_* functions
case "$assert_fn" in
assert_exit_code)
output=$(bashunit::main::handle_assert_exit_code "$last_arg")
Expand All@@ -1213,10 +1218,8 @@ function bashunit::main::exec_assert() {
assert_fn="assert_same"
fi

# Set a friendly test title for CLI assert command output
bashunit::state::set_test_title "assert ${original_assert_fn#assert_}"

# Run the assertion function and write into stderr
"$assert_fn" "${args[@]}" 1>&2
bashunit_exit_code=$?

Expand DownExpand Up@@ -1258,34 +1261,29 @@ function bashunit::main::exec_multi_assert() {
local cmd="$1"
shift

# Require at least one assertion
if [ $# -lt 1 ]; then
printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
printf "Usage: bashunit assert \"<command>\" <assertion1> <arg1> [<assertion2> <arg2>...]\n" 1>&2
return 1
fi

# Check that assertions come in pairs (assertion + arg)
if [ $# -lt 2 ] || [ $(($# % 2)) -ne 0 ]; then
local assertion_name="${1:-}"
printf "%sError: Missing argument for assertion '%s'.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
return 1
fi

# Execute command and capture output + exit code
local stdout
local cmd_exit_code
stdout=$(eval "$cmd" 2>&1)
cmd_exit_code=$?

# Print stdout for user visibility
if [ -n "$stdout" ]; then
echo "$stdout" 1>&1
fi

# Parse and execute assertions in pairs
local overall_result=0
while [ $# -gt 0 ]; do
local assertion_name="$1"
Expand All@@ -1299,7 +1297,6 @@ function bashunit::main::exec_multi_assert() {

shift 2

# Resolve assertion function name
local assert_fn="$assertion_name"
if ! type "$assert_fn" &>/dev/null; then
assert_fn="assert_$assertion_name"
Expand All@@ -1310,10 +1307,8 @@ function bashunit::main::exec_multi_assert() {
fi
fi

# Set test title for this assertion
bashunit::state::set_test_title "assert ${assertion_name#assert_}"

# Execute assertion with appropriate argument
if bashunit::main::is_exit_code_assertion "$assertion_name"; then
# Exit code assertion: pass expected value and captured exit code
"$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@
- 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
- `BASHUNIT_COVERAGE_THRESHOLD_LOW`/`BASHUNIT_COVERAGE_THRESHOLD_HIGH` (env-only, no CLI flag) now validate as non-negative integers like the other numeric settings. They are compared with `[ -ge ]` in the coverage class lookup, which errors instead of returning false on a non-integer value: a bad threshold leaked a raw `integer expression expected` into the coverage report and silently mis-bucketed every file's high/medium/low class
- `@max_ms` benchmark annotations with a decimal value (e.g. `@max_ms=100.5`) no longer leak a raw `integer expression expected` into the results table and always render as failing (`>`) regardless of the actual average; the threshold comparison now tolerates fractional operands like the average itself already could
- `bashunit assert <name>` with no arguments now reports a clear error and exits non-zero. It used to compute a `-1` array index, which Bash 3.x rejects, so it leaked a raw `bad array subscript` and still exited `0` β€” a malformed standalone assertion in a deploy script reported success (#877)
- A missing `--env`/`--boot` bootstrap file is now an error instead of a green run that tested nothing: the file used to be sourced unchecked, so a typo'd path leaked a raw `No such file or directory`, skipped the entire suite and still exited `0`. The report options (`--report-json`, `--log-junit`, `--report-tap`, `--report-html`, `--log-gha`) are likewise checked before the run instead of failing silently after it β€” an unwritable destination produced a passing run, no report on disk, and exit `0`. `--seed` now validates as a non-negative integer like the other numeric options (#875)
- The numeric options (`--jobs`, `--retry`, `--test-timeout`, `--coverage-min`) and `--output` now reject an invalid value with a clear error and a non-zero exit, instead of dropping the setting. `--jobs abc` was the worst case: `[ n -lt abc ]` errors rather than returning false, so the Bash 3.x job-slot poll never reached its `break` and the run **hung**, while Bash 4.3+ silently ignored the concurrency limit. `--coverage-min abc` leaked a raw `[: abc: integer expression expected` and still exited `0`, so a CI job never enforced its threshold. Validation runs on the resolved configuration, so it covers the `BASHUNIT_*` environment variables too (#873)
Expand Down
2 changes: 1 addition & 1 deletion src/benchmark.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,7 +143,7 @@ function bashunit::benchmark::print_results() {
continue
fi

if [ "$avg" -le "$max_ms" ]; then
if bashunit::math::is_le "$avg" "$max_ms"; then
local raw="≀ ${max_ms}"
local padded
padded=$(printf "%14s" "$raw")
Expand Down
99 changes: 46 additions & 53 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,9 +156,9 @@ function bashunit::coverage::get_tracked_files() {
# Get coverage class (high/medium/low) based on percentage
function bashunit::coverage::get_coverage_class() {
local pct="$1"
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" ]; then
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
echo "high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" ]; then
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
echo "medium"
else
echo "low"
Expand DownExpand Up@@ -610,11 +610,7 @@ function bashunit::coverage::get_hit_lines() {
function bashunit::coverage::compute_file_coverage() {
local file="$1"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local executable=0 hit=0 lineno=0 line line_hits
local -a cv_lines=()
Expand All@@ -628,7 +624,7 @@ function bashunit::coverage::compute_file_coverage() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
line_hits=${hits_by_line[lineno]:-0}
line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}
[ "$line_hits" -gt 0 ] && ((++hit))
done

Expand DownExpand Up@@ -715,6 +711,26 @@ function bashunit::coverage::get_all_line_hits() {
return 0
}

# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by
# line number -> hit count) from get_all_line_hits for $1.
#
# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)"
# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an
# array from a function or pass one by reference, and copying a sparse array
# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would
# corrupt the line-number keys -- so this loader writes one shared global
# instead (return-slot pattern, see bash-style.md). Callers must consume the
# result before the next call; there is no adjacent-call isolation.
declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE
function bashunit::coverage::load_hits_by_line() {
local file="$1"
_BASHUNIT_COVERAGE_HITS_BY_LINE=()
local hl_lineno hl_count
while IFS=: read -r hl_lineno hl_count; do
[ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count
done < <(bashunit::coverage::get_all_line_hits "$file")
}

# Get all test hits for a file in one pass (performance optimization)
# Output format: lineno|test_file:test_function (may have duplicates, one per hit)
function bashunit::coverage::get_all_line_tests() {
Expand DownExpand Up@@ -1012,17 +1028,18 @@ function bashunit::coverage::extract_branches() {
}

# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in
# [arm_start..arm_end] has a recorded hit, else 0. Caller must have
# populated the hits_by_line and src_lines arrays in scope; Bash 3.0
# cannot pass arrays into a function. Result is returned via the
# global to avoid a per-arm subshell.
# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from
# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see
# load_hits_by_line); caller must have populated the src_lines array in
# scope -- Bash 3.0 cannot pass arrays into a function. Result is
# returned via the global to avoid a per-arm subshell.
_BASHUNIT_ARM_TAKEN_OUT=0
function bashunit::coverage::_arm_taken() {
local arm_start="$1" arm_end="$2" ln
for ((ln = arm_start; ln <= arm_end; ln++)); do
bashunit::coverage::is_executable_line \
"${src_lines[$((ln - 1))]:-}" "$ln" || continue
if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then
_BASHUNIT_ARM_TAKEN_OUT=1
return
fi
Expand All@@ -1039,11 +1056,7 @@ function bashunit::coverage::_arm_taken() {
function bashunit::coverage::compute_branch_hits() {
local file="$1"

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a src_lines=()
local _sli=0 _sl
Expand DownExpand Up@@ -1218,19 +1231,15 @@ function bashunit::coverage::report_text_uncovered() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a uncovered_lines=()
local _ucount=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -eq 0 ]; then
uncovered_lines[_ucount]="$lineno"
_ucount=$((_ucount + 1))
Expand DownExpand Up@@ -1266,19 +1275,15 @@ function bashunit::coverage::report_text_line_hits() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a hit_specs=()
local _hc=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -gt 0 ]; then
hit_specs[_hc]="${lineno}:${lh}"
_hc=$((_hc + 1))
Expand DownExpand Up@@ -1311,11 +1316,7 @@ function bashunit::coverage::report_text_functions() {
functions_data=$(bashunit::coverage::extract_functions "$file")
[ -z "$functions_data" ] && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a file_lines=()
local _fli=0 _fl
Expand DownExpand Up@@ -1345,7 +1346,7 @@ function bashunit::coverage::report_text_functions() {
bashunit::coverage::is_executable_line \
"${file_lines[$((ln - 1))]:-}" "$ln" || continue
fn_executable=$((fn_executable + 1))
[ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
Expand DownExpand Up@@ -1377,11 +1378,7 @@ function bashunit::coverage::report_lcov() {

echo "SF:$file"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk
# and buffer the matching FNDA lines for emission after, per
Expand All@@ -1396,7 +1393,7 @@ function bashunit::coverage::report_lcov() {

any_hit=0
for ((fln = fn_start; fln <= fn_end; fln++)); do
if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then
any_hit=1
break
fi
Expand DownExpand Up@@ -1436,7 +1433,7 @@ function bashunit::coverage::report_lcov() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done
Expand DownExpand Up@@ -1824,19 +1821,19 @@ EOF
<div class="legend-item">
<span class="legend-color high"></span>
EOF
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High</span>"
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color medium"></span>
EOF
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium</span>"
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color low"></span>
EOF
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low</span>"
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -1921,11 +1918,7 @@ function bashunit::coverage::generate_file_html() {
local uncovered=$((executable - hit))

# Pre-load all line hits into indexed array (performance optimization)
local -a hits_by_line=()
local _ln _cnt
while IFS=: read -r _ln _cnt; do
hits_by_line[_ln]=$_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
Expand DownExpand Up@@ -2206,7 +2199,7 @@ EOF
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}
if [ "$ln_hits" -gt 0 ]; then
((++fn_hit))
fi
Expand DownExpand Up@@ -2269,7 +2262,7 @@ EOF

if bashunit::coverage::is_executable_line "$line" "$lineno"; then
# O(1) lookup from pre-loaded array
local hits=${hits_by_line[$lineno]:-0}
local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}

if [ "$hits" -gt 0 ]; then
row_class="covered"
Expand Down
21 changes: 8 additions & 13 deletions src/main.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,14 @@ function bashunit::main::validate_config_or_exit() {
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_MIN}" "BASHUNIT_COVERAGE_MIN (--coverage-min)"
fi
# Env-only (no CLI flag). Compared with `[ -ge ]` in
# bashunit::coverage::get_coverage_class, which errors instead of returning
# false on a non-integer operand, leaking a raw shell error into the
# coverage report and silently mis-bucketing every file's class (#879).
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" "BASHUNIT_COVERAGE_THRESHOLD_LOW"
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" "BASHUNIT_COVERAGE_THRESHOLD_HIGH"

if [ -n "${BASHUNIT_SEED:-}" ]; then
bashunit::main::require_non_negative_int_or_exit "${BASHUNIT_SEED}" "BASHUNIT_SEED (--seed)"
Expand DownExpand Up@@ -1168,7 +1176,6 @@ function bashunit::main::exec_assert() {

local assert_fn=$original_assert_fn

# Check if the function exists
if ! type "$assert_fn" >/dev/null 2>&1; then
assert_fn="assert_$assert_fn"
if ! type "$assert_fn" >/dev/null 2>&1; then
Expand All@@ -1187,14 +1194,12 @@ function bashunit::main::exec_assert() {
exit 1
fi

# Get the last argument safely by calculating the array length
local last_index=$((args_count - 1))
local last_arg="${args[$last_index]}"
local output=""
local inner_exit_code=0
local bashunit_exit_code=0

# Handle different assert_* functions
case "$assert_fn" in
assert_exit_code)
output=$(bashunit::main::handle_assert_exit_code "$last_arg")
Expand All@@ -1213,10 +1218,8 @@ function bashunit::main::exec_assert() {
assert_fn="assert_same"
fi

# Set a friendly test title for CLI assert command output
bashunit::state::set_test_title "assert ${original_assert_fn#assert_}"

# Run the assertion function and write into stderr
"$assert_fn" "${args[@]}" 1>&2
bashunit_exit_code=$?

Expand DownExpand Up@@ -1258,34 +1261,29 @@ function bashunit::main::exec_multi_assert() {
local cmd="$1"
shift

# Require at least one assertion
if [ $# -lt 1 ]; then
printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
printf "Usage: bashunit assert \"<command>\" <assertion1> <arg1> [<assertion2> <arg2>...]\n" 1>&2
return 1
fi

# Check that assertions come in pairs (assertion + arg)
if [ $# -lt 2 ] || [ $(($# % 2)) -ne 0 ]; then
local assertion_name="${1:-}"
printf "%sError: Missing argument for assertion '%s'.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
return 1
fi

# Execute command and capture output + exit code
local stdout
local cmd_exit_code
stdout=$(eval "$cmd" 2>&1)
cmd_exit_code=$?

# Print stdout for user visibility
if [ -n "$stdout" ]; then
echo "$stdout" 1>&1
fi

# Parse and execute assertions in pairs
local overall_result=0
while [ $# -gt 0 ]; do
local assertion_name="$1"
Expand All@@ -1299,7 +1297,6 @@ function bashunit::main::exec_multi_assert() {

shift 2

# Resolve assertion function name
local assert_fn="$assertion_name"
if ! type "$assert_fn" &>/dev/null; then
assert_fn="assert_$assertion_name"
Expand All@@ -1310,10 +1307,8 @@ function bashunit::main::exec_multi_assert() {
fi
fi

# Set test title for this assertion
bashunit::state::set_test_title "assert ${assertion_name#assert_}"

# Execute assertion with appropriate argument
if bashunit::main::is_exit_code_assertion "$assertion_name"; then
# Exit code assertion: pass expected value and captured exit code
"$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@
- 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
- `BASHUNIT_COVERAGE_THRESHOLD_LOW`/`BASHUNIT_COVERAGE_THRESHOLD_HIGH` (env-only, no CLI flag) now validate as non-negative integers like the other numeric settings. They are compared with `[ -ge ]` in the coverage class lookup, which errors instead of returning false on a non-integer value: a bad threshold leaked a raw `integer expression expected` into the coverage report and silently mis-bucketed every file's high/medium/low class
- `@max_ms` benchmark annotations with a decimal value (e.g. `@max_ms=100.5`) no longer leak a raw `integer expression expected` into the results table and always render as failing (`>`) regardless of the actual average; the threshold comparison now tolerates fractional operands like the average itself already could
- `bashunit assert <name>` with no arguments now reports a clear error and exits non-zero. It used to compute a `-1` array index, which Bash 3.x rejects, so it leaked a raw `bad array subscript` and still exited `0` β€” a malformed standalone assertion in a deploy script reported success (#877)
- A missing `--env`/`--boot` bootstrap file is now an error instead of a green run that tested nothing: the file used to be sourced unchecked, so a typo'd path leaked a raw `No such file or directory`, skipped the entire suite and still exited `0`. The report options (`--report-json`, `--log-junit`, `--report-tap`, `--report-html`, `--log-gha`) are likewise checked before the run instead of failing silently after it β€” an unwritable destination produced a passing run, no report on disk, and exit `0`. `--seed` now validates as a non-negative integer like the other numeric options (#875)
- The numeric options (`--jobs`, `--retry`, `--test-timeout`, `--coverage-min`) and `--output` now reject an invalid value with a clear error and a non-zero exit, instead of dropping the setting. `--jobs abc` was the worst case: `[ n -lt abc ]` errors rather than returning false, so the Bash 3.x job-slot poll never reached its `break` and the run **hung**, while Bash 4.3+ silently ignored the concurrency limit. `--coverage-min abc` leaked a raw `[: abc: integer expression expected` and still exited `0`, so a CI job never enforced its threshold. Validation runs on the resolved configuration, so it covers the `BASHUNIT_*` environment variables too (#873)
Expand Down
2 changes: 1 addition & 1 deletion src/benchmark.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,7 +143,7 @@ function bashunit::benchmark::print_results() {
continue
fi

if [ "$avg" -le "$max_ms" ]; then
if bashunit::math::is_le "$avg" "$max_ms"; then
local raw="≀ ${max_ms}"
local padded
padded=$(printf "%14s" "$raw")
Expand Down
99 changes: 46 additions & 53 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,9 +156,9 @@ function bashunit::coverage::get_tracked_files() {
# Get coverage class (high/medium/low) based on percentage
function bashunit::coverage::get_coverage_class() {
local pct="$1"
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" ]; then
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
echo "high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" ]; then
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
echo "medium"
else
echo "low"
Expand DownExpand Up@@ -610,11 +610,7 @@ function bashunit::coverage::get_hit_lines() {
function bashunit::coverage::compute_file_coverage() {
local file="$1"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local executable=0 hit=0 lineno=0 line line_hits
local -a cv_lines=()
Expand All@@ -628,7 +624,7 @@ function bashunit::coverage::compute_file_coverage() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
line_hits=${hits_by_line[lineno]:-0}
line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}
[ "$line_hits" -gt 0 ] && ((++hit))
done

Expand DownExpand Up@@ -715,6 +711,26 @@ function bashunit::coverage::get_all_line_hits() {
return 0
}

# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by
# line number -> hit count) from get_all_line_hits for $1.
#
# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)"
# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an
# array from a function or pass one by reference, and copying a sparse array
# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would
# corrupt the line-number keys -- so this loader writes one shared global
# instead (return-slot pattern, see bash-style.md). Callers must consume the
# result before the next call; there is no adjacent-call isolation.
declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE
function bashunit::coverage::load_hits_by_line() {
local file="$1"
_BASHUNIT_COVERAGE_HITS_BY_LINE=()
local hl_lineno hl_count
while IFS=: read -r hl_lineno hl_count; do
[ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count
done < <(bashunit::coverage::get_all_line_hits "$file")
}

# Get all test hits for a file in one pass (performance optimization)
# Output format: lineno|test_file:test_function (may have duplicates, one per hit)
function bashunit::coverage::get_all_line_tests() {
Expand DownExpand Up@@ -1012,17 +1028,18 @@ function bashunit::coverage::extract_branches() {
}

# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in
# [arm_start..arm_end] has a recorded hit, else 0. Caller must have
# populated the hits_by_line and src_lines arrays in scope; Bash 3.0
# cannot pass arrays into a function. Result is returned via the
# global to avoid a per-arm subshell.
# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from
# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see
# load_hits_by_line); caller must have populated the src_lines array in
# scope -- Bash 3.0 cannot pass arrays into a function. Result is
# returned via the global to avoid a per-arm subshell.
_BASHUNIT_ARM_TAKEN_OUT=0
function bashunit::coverage::_arm_taken() {
local arm_start="$1" arm_end="$2" ln
for ((ln = arm_start; ln <= arm_end; ln++)); do
bashunit::coverage::is_executable_line \
"${src_lines[$((ln - 1))]:-}" "$ln" || continue
if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then
_BASHUNIT_ARM_TAKEN_OUT=1
return
fi
Expand All@@ -1039,11 +1056,7 @@ function bashunit::coverage::_arm_taken() {
function bashunit::coverage::compute_branch_hits() {
local file="$1"

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a src_lines=()
local _sli=0 _sl
Expand DownExpand Up@@ -1218,19 +1231,15 @@ function bashunit::coverage::report_text_uncovered() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a uncovered_lines=()
local _ucount=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -eq 0 ]; then
uncovered_lines[_ucount]="$lineno"
_ucount=$((_ucount + 1))
Expand DownExpand Up@@ -1266,19 +1275,15 @@ function bashunit::coverage::report_text_line_hits() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a hit_specs=()
local _hc=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -gt 0 ]; then
hit_specs[_hc]="${lineno}:${lh}"
_hc=$((_hc + 1))
Expand DownExpand Up@@ -1311,11 +1316,7 @@ function bashunit::coverage::report_text_functions() {
functions_data=$(bashunit::coverage::extract_functions "$file")
[ -z "$functions_data" ] && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a file_lines=()
local _fli=0 _fl
Expand DownExpand Up@@ -1345,7 +1346,7 @@ function bashunit::coverage::report_text_functions() {
bashunit::coverage::is_executable_line \
"${file_lines[$((ln - 1))]:-}" "$ln" || continue
fn_executable=$((fn_executable + 1))
[ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
Expand DownExpand Up@@ -1377,11 +1378,7 @@ function bashunit::coverage::report_lcov() {

echo "SF:$file"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk
# and buffer the matching FNDA lines for emission after, per
Expand All@@ -1396,7 +1393,7 @@ function bashunit::coverage::report_lcov() {

any_hit=0
for ((fln = fn_start; fln <= fn_end; fln++)); do
if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then
any_hit=1
break
fi
Expand DownExpand Up@@ -1436,7 +1433,7 @@ function bashunit::coverage::report_lcov() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done
Expand DownExpand Up@@ -1824,19 +1821,19 @@ EOF
<div class="legend-item">
<span class="legend-color high"></span>
EOF
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High</span>"
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color medium"></span>
EOF
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium</span>"
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color low"></span>
EOF
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low</span>"
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -1921,11 +1918,7 @@ function bashunit::coverage::generate_file_html() {
local uncovered=$((executable - hit))

# Pre-load all line hits into indexed array (performance optimization)
local -a hits_by_line=()
local _ln _cnt
while IFS=: read -r _ln _cnt; do
hits_by_line[_ln]=$_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
Expand DownExpand Up@@ -2206,7 +2199,7 @@ EOF
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}
if [ "$ln_hits" -gt 0 ]; then
((++fn_hit))
fi
Expand DownExpand Up@@ -2269,7 +2262,7 @@ EOF

if bashunit::coverage::is_executable_line "$line" "$lineno"; then
# O(1) lookup from pre-loaded array
local hits=${hits_by_line[$lineno]:-0}
local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}

if [ "$hits" -gt 0 ]; then
row_class="covered"
Expand Down
21 changes: 8 additions & 13 deletions src/main.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,14 @@ function bashunit::main::validate_config_or_exit() {
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_MIN}" "BASHUNIT_COVERAGE_MIN (--coverage-min)"
fi
# Env-only (no CLI flag). Compared with `[ -ge ]` in
# bashunit::coverage::get_coverage_class, which errors instead of returning
# false on a non-integer operand, leaking a raw shell error into the
# coverage report and silently mis-bucketing every file's class (#879).
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" "BASHUNIT_COVERAGE_THRESHOLD_LOW"
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" "BASHUNIT_COVERAGE_THRESHOLD_HIGH"

if [ -n "${BASHUNIT_SEED:-}" ]; then
bashunit::main::require_non_negative_int_or_exit "${BASHUNIT_SEED}" "BASHUNIT_SEED (--seed)"
Expand DownExpand Up@@ -1168,7 +1176,6 @@ function bashunit::main::exec_assert() {

local assert_fn=$original_assert_fn

# Check if the function exists
if ! type "$assert_fn" >/dev/null 2>&1; then
assert_fn="assert_$assert_fn"
if ! type "$assert_fn" >/dev/null 2>&1; then
Expand All@@ -1187,14 +1194,12 @@ function bashunit::main::exec_assert() {
exit 1
fi

# Get the last argument safely by calculating the array length
local last_index=$((args_count - 1))
local last_arg="${args[$last_index]}"
local output=""
local inner_exit_code=0
local bashunit_exit_code=0

# Handle different assert_* functions
case "$assert_fn" in
assert_exit_code)
output=$(bashunit::main::handle_assert_exit_code "$last_arg")
Expand All@@ -1213,10 +1218,8 @@ function bashunit::main::exec_assert() {
assert_fn="assert_same"
fi

# Set a friendly test title for CLI assert command output
bashunit::state::set_test_title "assert ${original_assert_fn#assert_}"

# Run the assertion function and write into stderr
"$assert_fn" "${args[@]}" 1>&2
bashunit_exit_code=$?

Expand DownExpand Up@@ -1258,34 +1261,29 @@ function bashunit::main::exec_multi_assert() {
local cmd="$1"
shift

# Require at least one assertion
if [ $# -lt 1 ]; then
printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
printf "Usage: bashunit assert \"<command>\" <assertion1> <arg1> [<assertion2> <arg2>...]\n" 1>&2
return 1
fi

# Check that assertions come in pairs (assertion + arg)
if [ $# -lt 2 ] || [ $(($# % 2)) -ne 0 ]; then
local assertion_name="${1:-}"
printf "%sError: Missing argument for assertion '%s'.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
return 1
fi

# Execute command and capture output + exit code
local stdout
local cmd_exit_code
stdout=$(eval "$cmd" 2>&1)
cmd_exit_code=$?

# Print stdout for user visibility
if [ -n "$stdout" ]; then
echo "$stdout" 1>&1
fi

# Parse and execute assertions in pairs
local overall_result=0
while [ $# -gt 0 ]; do
local assertion_name="$1"
Expand All@@ -1299,7 +1297,6 @@ function bashunit::main::exec_multi_assert() {

shift 2

# Resolve assertion function name
local assert_fn="$assertion_name"
if ! type "$assert_fn" &>/dev/null; then
assert_fn="assert_$assertion_name"
Expand All@@ -1310,10 +1307,8 @@ function bashunit::main::exec_multi_assert() {
fi
fi

# Set test title for this assertion
bashunit::state::set_test_title "assert ${assertion_name#assert_}"

# Execute assertion with appropriate argument
if bashunit::main::is_exit_code_assertion "$assertion_name"; then
# Exit code assertion: pass expected value and captured exit code
"$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@
- 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
- `BASHUNIT_COVERAGE_THRESHOLD_LOW`/`BASHUNIT_COVERAGE_THRESHOLD_HIGH` (env-only, no CLI flag) now validate as non-negative integers like the other numeric settings. They are compared with `[ -ge ]` in the coverage class lookup, which errors instead of returning false on a non-integer value: a bad threshold leaked a raw `integer expression expected` into the coverage report and silently mis-bucketed every file's high/medium/low class
- `@max_ms` benchmark annotations with a decimal value (e.g. `@max_ms=100.5`) no longer leak a raw `integer expression expected` into the results table and always render as failing (`>`) regardless of the actual average; the threshold comparison now tolerates fractional operands like the average itself already could
- `bashunit assert <name>` with no arguments now reports a clear error and exits non-zero. It used to compute a `-1` array index, which Bash 3.x rejects, so it leaked a raw `bad array subscript` and still exited `0` β€” a malformed standalone assertion in a deploy script reported success (#877)
- A missing `--env`/`--boot` bootstrap file is now an error instead of a green run that tested nothing: the file used to be sourced unchecked, so a typo'd path leaked a raw `No such file or directory`, skipped the entire suite and still exited `0`. The report options (`--report-json`, `--log-junit`, `--report-tap`, `--report-html`, `--log-gha`) are likewise checked before the run instead of failing silently after it β€” an unwritable destination produced a passing run, no report on disk, and exit `0`. `--seed` now validates as a non-negative integer like the other numeric options (#875)
- The numeric options (`--jobs`, `--retry`, `--test-timeout`, `--coverage-min`) and `--output` now reject an invalid value with a clear error and a non-zero exit, instead of dropping the setting. `--jobs abc` was the worst case: `[ n -lt abc ]` errors rather than returning false, so the Bash 3.x job-slot poll never reached its `break` and the run **hung**, while Bash 4.3+ silently ignored the concurrency limit. `--coverage-min abc` leaked a raw `[: abc: integer expression expected` and still exited `0`, so a CI job never enforced its threshold. Validation runs on the resolved configuration, so it covers the `BASHUNIT_*` environment variables too (#873)
Expand Down
2 changes: 1 addition & 1 deletion src/benchmark.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,7 +143,7 @@ function bashunit::benchmark::print_results() {
continue
fi

if [ "$avg" -le "$max_ms" ]; then
if bashunit::math::is_le "$avg" "$max_ms"; then
local raw="≀ ${max_ms}"
local padded
padded=$(printf "%14s" "$raw")
Expand Down
99 changes: 46 additions & 53 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,9 +156,9 @@ function bashunit::coverage::get_tracked_files() {
# Get coverage class (high/medium/low) based on percentage
function bashunit::coverage::get_coverage_class() {
local pct="$1"
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" ]; then
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
echo "high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" ]; then
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
echo "medium"
else
echo "low"
Expand DownExpand Up@@ -610,11 +610,7 @@ function bashunit::coverage::get_hit_lines() {
function bashunit::coverage::compute_file_coverage() {
local file="$1"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local executable=0 hit=0 lineno=0 line line_hits
local -a cv_lines=()
Expand All@@ -628,7 +624,7 @@ function bashunit::coverage::compute_file_coverage() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
line_hits=${hits_by_line[lineno]:-0}
line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}
[ "$line_hits" -gt 0 ] && ((++hit))
done

Expand DownExpand Up@@ -715,6 +711,26 @@ function bashunit::coverage::get_all_line_hits() {
return 0
}

# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by
# line number -> hit count) from get_all_line_hits for $1.
#
# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)"
# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an
# array from a function or pass one by reference, and copying a sparse array
# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would
# corrupt the line-number keys -- so this loader writes one shared global
# instead (return-slot pattern, see bash-style.md). Callers must consume the
# result before the next call; there is no adjacent-call isolation.
declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE
function bashunit::coverage::load_hits_by_line() {
local file="$1"
_BASHUNIT_COVERAGE_HITS_BY_LINE=()
local hl_lineno hl_count
while IFS=: read -r hl_lineno hl_count; do
[ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count
done < <(bashunit::coverage::get_all_line_hits "$file")
}

# Get all test hits for a file in one pass (performance optimization)
# Output format: lineno|test_file:test_function (may have duplicates, one per hit)
function bashunit::coverage::get_all_line_tests() {
Expand DownExpand Up@@ -1012,17 +1028,18 @@ function bashunit::coverage::extract_branches() {
}

# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in
# [arm_start..arm_end] has a recorded hit, else 0. Caller must have
# populated the hits_by_line and src_lines arrays in scope; Bash 3.0
# cannot pass arrays into a function. Result is returned via the
# global to avoid a per-arm subshell.
# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from
# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see
# load_hits_by_line); caller must have populated the src_lines array in
# scope -- Bash 3.0 cannot pass arrays into a function. Result is
# returned via the global to avoid a per-arm subshell.
_BASHUNIT_ARM_TAKEN_OUT=0
function bashunit::coverage::_arm_taken() {
local arm_start="$1" arm_end="$2" ln
for ((ln = arm_start; ln <= arm_end; ln++)); do
bashunit::coverage::is_executable_line \
"${src_lines[$((ln - 1))]:-}" "$ln" || continue
if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then
_BASHUNIT_ARM_TAKEN_OUT=1
return
fi
Expand All@@ -1039,11 +1056,7 @@ function bashunit::coverage::_arm_taken() {
function bashunit::coverage::compute_branch_hits() {
local file="$1"

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a src_lines=()
local _sli=0 _sl
Expand DownExpand Up@@ -1218,19 +1231,15 @@ function bashunit::coverage::report_text_uncovered() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a uncovered_lines=()
local _ucount=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -eq 0 ]; then
uncovered_lines[_ucount]="$lineno"
_ucount=$((_ucount + 1))
Expand DownExpand Up@@ -1266,19 +1275,15 @@ function bashunit::coverage::report_text_line_hits() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a hit_specs=()
local _hc=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -gt 0 ]; then
hit_specs[_hc]="${lineno}:${lh}"
_hc=$((_hc + 1))
Expand DownExpand Up@@ -1311,11 +1316,7 @@ function bashunit::coverage::report_text_functions() {
functions_data=$(bashunit::coverage::extract_functions "$file")
[ -z "$functions_data" ] && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a file_lines=()
local _fli=0 _fl
Expand DownExpand Up@@ -1345,7 +1346,7 @@ function bashunit::coverage::report_text_functions() {
bashunit::coverage::is_executable_line \
"${file_lines[$((ln - 1))]:-}" "$ln" || continue
fn_executable=$((fn_executable + 1))
[ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
Expand DownExpand Up@@ -1377,11 +1378,7 @@ function bashunit::coverage::report_lcov() {

echo "SF:$file"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk
# and buffer the matching FNDA lines for emission after, per
Expand All@@ -1396,7 +1393,7 @@ function bashunit::coverage::report_lcov() {

any_hit=0
for ((fln = fn_start; fln <= fn_end; fln++)); do
if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then
any_hit=1
break
fi
Expand DownExpand Up@@ -1436,7 +1433,7 @@ function bashunit::coverage::report_lcov() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done
Expand DownExpand Up@@ -1824,19 +1821,19 @@ EOF
<div class="legend-item">
<span class="legend-color high"></span>
EOF
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High</span>"
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color medium"></span>
EOF
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium</span>"
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color low"></span>
EOF
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low</span>"
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -1921,11 +1918,7 @@ function bashunit::coverage::generate_file_html() {
local uncovered=$((executable - hit))

# Pre-load all line hits into indexed array (performance optimization)
local -a hits_by_line=()
local _ln _cnt
while IFS=: read -r _ln _cnt; do
hits_by_line[_ln]=$_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
Expand DownExpand Up@@ -2206,7 +2199,7 @@ EOF
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}
if [ "$ln_hits" -gt 0 ]; then
((++fn_hit))
fi
Expand DownExpand Up@@ -2269,7 +2262,7 @@ EOF

if bashunit::coverage::is_executable_line "$line" "$lineno"; then
# O(1) lookup from pre-loaded array
local hits=${hits_by_line[$lineno]:-0}
local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}

if [ "$hits" -gt 0 ]; then
row_class="covered"
Expand Down
21 changes: 8 additions & 13 deletions src/main.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,14 @@ function bashunit::main::validate_config_or_exit() {
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_MIN}" "BASHUNIT_COVERAGE_MIN (--coverage-min)"
fi
# Env-only (no CLI flag). Compared with `[ -ge ]` in
# bashunit::coverage::get_coverage_class, which errors instead of returning
# false on a non-integer operand, leaking a raw shell error into the
# coverage report and silently mis-bucketing every file's class (#879).
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" "BASHUNIT_COVERAGE_THRESHOLD_LOW"
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" "BASHUNIT_COVERAGE_THRESHOLD_HIGH"

if [ -n "${BASHUNIT_SEED:-}" ]; then
bashunit::main::require_non_negative_int_or_exit "${BASHUNIT_SEED}" "BASHUNIT_SEED (--seed)"
Expand DownExpand Up@@ -1168,7 +1176,6 @@ function bashunit::main::exec_assert() {

local assert_fn=$original_assert_fn

# Check if the function exists
if ! type "$assert_fn" >/dev/null 2>&1; then
assert_fn="assert_$assert_fn"
if ! type "$assert_fn" >/dev/null 2>&1; then
Expand All@@ -1187,14 +1194,12 @@ function bashunit::main::exec_assert() {
exit 1
fi

# Get the last argument safely by calculating the array length
local last_index=$((args_count - 1))
local last_arg="${args[$last_index]}"
local output=""
local inner_exit_code=0
local bashunit_exit_code=0

# Handle different assert_* functions
case "$assert_fn" in
assert_exit_code)
output=$(bashunit::main::handle_assert_exit_code "$last_arg")
Expand All@@ -1213,10 +1218,8 @@ function bashunit::main::exec_assert() {
assert_fn="assert_same"
fi

# Set a friendly test title for CLI assert command output
bashunit::state::set_test_title "assert ${original_assert_fn#assert_}"

# Run the assertion function and write into stderr
"$assert_fn" "${args[@]}" 1>&2
bashunit_exit_code=$?

Expand DownExpand Up@@ -1258,34 +1261,29 @@ function bashunit::main::exec_multi_assert() {
local cmd="$1"
shift

# Require at least one assertion
if [ $# -lt 1 ]; then
printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
printf "Usage: bashunit assert \"<command>\" <assertion1> <arg1> [<assertion2> <arg2>...]\n" 1>&2
return 1
fi

# Check that assertions come in pairs (assertion + arg)
if [ $# -lt 2 ] || [ $(($# % 2)) -ne 0 ]; then
local assertion_name="${1:-}"
printf "%sError: Missing argument for assertion '%s'.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
return 1
fi

# Execute command and capture output + exit code
local stdout
local cmd_exit_code
stdout=$(eval "$cmd" 2>&1)
cmd_exit_code=$?

# Print stdout for user visibility
if [ -n "$stdout" ]; then
echo "$stdout" 1>&1
fi

# Parse and execute assertions in pairs
local overall_result=0
while [ $# -gt 0 ]; do
local assertion_name="$1"
Expand All@@ -1299,7 +1297,6 @@ function bashunit::main::exec_multi_assert() {

shift 2

# Resolve assertion function name
local assert_fn="$assertion_name"
if ! type "$assert_fn" &>/dev/null; then
assert_fn="assert_$assertion_name"
Expand All@@ -1310,10 +1307,8 @@ function bashunit::main::exec_multi_assert() {
fi
fi

# Set test title for this assertion
bashunit::state::set_test_title "assert ${assertion_name#assert_}"

# Execute assertion with appropriate argument
if bashunit::main::is_exit_code_assertion "$assertion_name"; then
# Exit code assertion: pass expected value and captured exit code
"$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@
- 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
- `BASHUNIT_COVERAGE_THRESHOLD_LOW`/`BASHUNIT_COVERAGE_THRESHOLD_HIGH` (env-only, no CLI flag) now validate as non-negative integers like the other numeric settings. They are compared with `[ -ge ]` in the coverage class lookup, which errors instead of returning false on a non-integer value: a bad threshold leaked a raw `integer expression expected` into the coverage report and silently mis-bucketed every file's high/medium/low class
- `@max_ms` benchmark annotations with a decimal value (e.g. `@max_ms=100.5`) no longer leak a raw `integer expression expected` into the results table and always render as failing (`>`) regardless of the actual average; the threshold comparison now tolerates fractional operands like the average itself already could
- `bashunit assert <name>` with no arguments now reports a clear error and exits non-zero. It used to compute a `-1` array index, which Bash 3.x rejects, so it leaked a raw `bad array subscript` and still exited `0` β€” a malformed standalone assertion in a deploy script reported success (#877)
- A missing `--env`/`--boot` bootstrap file is now an error instead of a green run that tested nothing: the file used to be sourced unchecked, so a typo'd path leaked a raw `No such file or directory`, skipped the entire suite and still exited `0`. The report options (`--report-json`, `--log-junit`, `--report-tap`, `--report-html`, `--log-gha`) are likewise checked before the run instead of failing silently after it β€” an unwritable destination produced a passing run, no report on disk, and exit `0`. `--seed` now validates as a non-negative integer like the other numeric options (#875)
- The numeric options (`--jobs`, `--retry`, `--test-timeout`, `--coverage-min`) and `--output` now reject an invalid value with a clear error and a non-zero exit, instead of dropping the setting. `--jobs abc` was the worst case: `[ n -lt abc ]` errors rather than returning false, so the Bash 3.x job-slot poll never reached its `break` and the run **hung**, while Bash 4.3+ silently ignored the concurrency limit. `--coverage-min abc` leaked a raw `[: abc: integer expression expected` and still exited `0`, so a CI job never enforced its threshold. Validation runs on the resolved configuration, so it covers the `BASHUNIT_*` environment variables too (#873)
Expand Down
2 changes: 1 addition & 1 deletion src/benchmark.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,7 +143,7 @@ function bashunit::benchmark::print_results() {
continue
fi

if [ "$avg" -le "$max_ms" ]; then
if bashunit::math::is_le "$avg" "$max_ms"; then
local raw="≀ ${max_ms}"
local padded
padded=$(printf "%14s" "$raw")
Expand Down
99 changes: 46 additions & 53 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,9 +156,9 @@ function bashunit::coverage::get_tracked_files() {
# Get coverage class (high/medium/low) based on percentage
function bashunit::coverage::get_coverage_class() {
local pct="$1"
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" ]; then
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
echo "high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" ]; then
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
echo "medium"
else
echo "low"
Expand DownExpand Up@@ -610,11 +610,7 @@ function bashunit::coverage::get_hit_lines() {
function bashunit::coverage::compute_file_coverage() {
local file="$1"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local executable=0 hit=0 lineno=0 line line_hits
local -a cv_lines=()
Expand All@@ -628,7 +624,7 @@ function bashunit::coverage::compute_file_coverage() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
line_hits=${hits_by_line[lineno]:-0}
line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}
[ "$line_hits" -gt 0 ] && ((++hit))
done

Expand DownExpand Up@@ -715,6 +711,26 @@ function bashunit::coverage::get_all_line_hits() {
return 0
}

# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by
# line number -> hit count) from get_all_line_hits for $1.
#
# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)"
# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an
# array from a function or pass one by reference, and copying a sparse array
# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would
# corrupt the line-number keys -- so this loader writes one shared global
# instead (return-slot pattern, see bash-style.md). Callers must consume the
# result before the next call; there is no adjacent-call isolation.
declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE
function bashunit::coverage::load_hits_by_line() {
local file="$1"
_BASHUNIT_COVERAGE_HITS_BY_LINE=()
local hl_lineno hl_count
while IFS=: read -r hl_lineno hl_count; do
[ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count
done < <(bashunit::coverage::get_all_line_hits "$file")
}

# Get all test hits for a file in one pass (performance optimization)
# Output format: lineno|test_file:test_function (may have duplicates, one per hit)
function bashunit::coverage::get_all_line_tests() {
Expand DownExpand Up@@ -1012,17 +1028,18 @@ function bashunit::coverage::extract_branches() {
}

# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in
# [arm_start..arm_end] has a recorded hit, else 0. Caller must have
# populated the hits_by_line and src_lines arrays in scope; Bash 3.0
# cannot pass arrays into a function. Result is returned via the
# global to avoid a per-arm subshell.
# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from
# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see
# load_hits_by_line); caller must have populated the src_lines array in
# scope -- Bash 3.0 cannot pass arrays into a function. Result is
# returned via the global to avoid a per-arm subshell.
_BASHUNIT_ARM_TAKEN_OUT=0
function bashunit::coverage::_arm_taken() {
local arm_start="$1" arm_end="$2" ln
for ((ln = arm_start; ln <= arm_end; ln++)); do
bashunit::coverage::is_executable_line \
"${src_lines[$((ln - 1))]:-}" "$ln" || continue
if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then
_BASHUNIT_ARM_TAKEN_OUT=1
return
fi
Expand All@@ -1039,11 +1056,7 @@ function bashunit::coverage::_arm_taken() {
function bashunit::coverage::compute_branch_hits() {
local file="$1"

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a src_lines=()
local _sli=0 _sl
Expand DownExpand Up@@ -1218,19 +1231,15 @@ function bashunit::coverage::report_text_uncovered() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a uncovered_lines=()
local _ucount=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -eq 0 ]; then
uncovered_lines[_ucount]="$lineno"
_ucount=$((_ucount + 1))
Expand DownExpand Up@@ -1266,19 +1275,15 @@ function bashunit::coverage::report_text_line_hits() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a hit_specs=()
local _hc=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -gt 0 ]; then
hit_specs[_hc]="${lineno}:${lh}"
_hc=$((_hc + 1))
Expand DownExpand Up@@ -1311,11 +1316,7 @@ function bashunit::coverage::report_text_functions() {
functions_data=$(bashunit::coverage::extract_functions "$file")
[ -z "$functions_data" ] && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a file_lines=()
local _fli=0 _fl
Expand DownExpand Up@@ -1345,7 +1346,7 @@ function bashunit::coverage::report_text_functions() {
bashunit::coverage::is_executable_line \
"${file_lines[$((ln - 1))]:-}" "$ln" || continue
fn_executable=$((fn_executable + 1))
[ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
Expand DownExpand Up@@ -1377,11 +1378,7 @@ function bashunit::coverage::report_lcov() {

echo "SF:$file"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk
# and buffer the matching FNDA lines for emission after, per
Expand All@@ -1396,7 +1393,7 @@ function bashunit::coverage::report_lcov() {

any_hit=0
for ((fln = fn_start; fln <= fn_end; fln++)); do
if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then
any_hit=1
break
fi
Expand DownExpand Up@@ -1436,7 +1433,7 @@ function bashunit::coverage::report_lcov() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done
Expand DownExpand Up@@ -1824,19 +1821,19 @@ EOF
<div class="legend-item">
<span class="legend-color high"></span>
EOF
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High</span>"
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color medium"></span>
EOF
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium</span>"
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color low"></span>
EOF
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low</span>"
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -1921,11 +1918,7 @@ function bashunit::coverage::generate_file_html() {
local uncovered=$((executable - hit))

# Pre-load all line hits into indexed array (performance optimization)
local -a hits_by_line=()
local _ln _cnt
while IFS=: read -r _ln _cnt; do
hits_by_line[_ln]=$_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
Expand DownExpand Up@@ -2206,7 +2199,7 @@ EOF
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}
if [ "$ln_hits" -gt 0 ]; then
((++fn_hit))
fi
Expand DownExpand Up@@ -2269,7 +2262,7 @@ EOF

if bashunit::coverage::is_executable_line "$line" "$lineno"; then
# O(1) lookup from pre-loaded array
local hits=${hits_by_line[$lineno]:-0}
local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}

if [ "$hits" -gt 0 ]; then
row_class="covered"
Expand Down
21 changes: 8 additions & 13 deletions src/main.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,14 @@ function bashunit::main::validate_config_or_exit() {
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_MIN}" "BASHUNIT_COVERAGE_MIN (--coverage-min)"
fi
# Env-only (no CLI flag). Compared with `[ -ge ]` in
# bashunit::coverage::get_coverage_class, which errors instead of returning
# false on a non-integer operand, leaking a raw shell error into the
# coverage report and silently mis-bucketing every file's class (#879).
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" "BASHUNIT_COVERAGE_THRESHOLD_LOW"
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" "BASHUNIT_COVERAGE_THRESHOLD_HIGH"

if [ -n "${BASHUNIT_SEED:-}" ]; then
bashunit::main::require_non_negative_int_or_exit "${BASHUNIT_SEED}" "BASHUNIT_SEED (--seed)"
Expand DownExpand Up@@ -1168,7 +1176,6 @@ function bashunit::main::exec_assert() {

local assert_fn=$original_assert_fn

# Check if the function exists
if ! type "$assert_fn" >/dev/null 2>&1; then
assert_fn="assert_$assert_fn"
if ! type "$assert_fn" >/dev/null 2>&1; then
Expand All@@ -1187,14 +1194,12 @@ function bashunit::main::exec_assert() {
exit 1
fi

# Get the last argument safely by calculating the array length
local last_index=$((args_count - 1))
local last_arg="${args[$last_index]}"
local output=""
local inner_exit_code=0
local bashunit_exit_code=0

# Handle different assert_* functions
case "$assert_fn" in
assert_exit_code)
output=$(bashunit::main::handle_assert_exit_code "$last_arg")
Expand All@@ -1213,10 +1218,8 @@ function bashunit::main::exec_assert() {
assert_fn="assert_same"
fi

# Set a friendly test title for CLI assert command output
bashunit::state::set_test_title "assert ${original_assert_fn#assert_}"

# Run the assertion function and write into stderr
"$assert_fn" "${args[@]}" 1>&2
bashunit_exit_code=$?

Expand DownExpand Up@@ -1258,34 +1261,29 @@ function bashunit::main::exec_multi_assert() {
local cmd="$1"
shift

# Require at least one assertion
if [ $# -lt 1 ]; then
printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
printf "Usage: bashunit assert \"<command>\" <assertion1> <arg1> [<assertion2> <arg2>...]\n" 1>&2
return 1
fi

# Check that assertions come in pairs (assertion + arg)
if [ $# -lt 2 ] || [ $(($# % 2)) -ne 0 ]; then
local assertion_name="${1:-}"
printf "%sError: Missing argument for assertion '%s'.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
return 1
fi

# Execute command and capture output + exit code
local stdout
local cmd_exit_code
stdout=$(eval "$cmd" 2>&1)
cmd_exit_code=$?

# Print stdout for user visibility
if [ -n "$stdout" ]; then
echo "$stdout" 1>&1
fi

# Parse and execute assertions in pairs
local overall_result=0
while [ $# -gt 0 ]; do
local assertion_name="$1"
Expand All@@ -1299,7 +1297,6 @@ function bashunit::main::exec_multi_assert() {

shift 2

# Resolve assertion function name
local assert_fn="$assertion_name"
if ! type "$assert_fn" &>/dev/null; then
assert_fn="assert_$assertion_name"
Expand All@@ -1310,10 +1307,8 @@ function bashunit::main::exec_multi_assert() {
fi
fi

# Set test title for this assertion
bashunit::state::set_test_title "assert ${assertion_name#assert_}"

# Execute assertion with appropriate argument
if bashunit::main::is_exit_code_assertion "$assertion_name"; then
# Exit code assertion: pass expected value and captured exit code
"$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@
- 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
- `BASHUNIT_COVERAGE_THRESHOLD_LOW`/`BASHUNIT_COVERAGE_THRESHOLD_HIGH` (env-only, no CLI flag) now validate as non-negative integers like the other numeric settings. They are compared with `[ -ge ]` in the coverage class lookup, which errors instead of returning false on a non-integer value: a bad threshold leaked a raw `integer expression expected` into the coverage report and silently mis-bucketed every file's high/medium/low class
- `@max_ms` benchmark annotations with a decimal value (e.g. `@max_ms=100.5`) no longer leak a raw `integer expression expected` into the results table and always render as failing (`>`) regardless of the actual average; the threshold comparison now tolerates fractional operands like the average itself already could
- `bashunit assert <name>` with no arguments now reports a clear error and exits non-zero. It used to compute a `-1` array index, which Bash 3.x rejects, so it leaked a raw `bad array subscript` and still exited `0` β€” a malformed standalone assertion in a deploy script reported success (#877)
- A missing `--env`/`--boot` bootstrap file is now an error instead of a green run that tested nothing: the file used to be sourced unchecked, so a typo'd path leaked a raw `No such file or directory`, skipped the entire suite and still exited `0`. The report options (`--report-json`, `--log-junit`, `--report-tap`, `--report-html`, `--log-gha`) are likewise checked before the run instead of failing silently after it β€” an unwritable destination produced a passing run, no report on disk, and exit `0`. `--seed` now validates as a non-negative integer like the other numeric options (#875)
- The numeric options (`--jobs`, `--retry`, `--test-timeout`, `--coverage-min`) and `--output` now reject an invalid value with a clear error and a non-zero exit, instead of dropping the setting. `--jobs abc` was the worst case: `[ n -lt abc ]` errors rather than returning false, so the Bash 3.x job-slot poll never reached its `break` and the run **hung**, while Bash 4.3+ silently ignored the concurrency limit. `--coverage-min abc` leaked a raw `[: abc: integer expression expected` and still exited `0`, so a CI job never enforced its threshold. Validation runs on the resolved configuration, so it covers the `BASHUNIT_*` environment variables too (#873)
Expand Down
2 changes: 1 addition & 1 deletion src/benchmark.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,7 +143,7 @@ function bashunit::benchmark::print_results() {
continue
fi

if [ "$avg" -le "$max_ms" ]; then
if bashunit::math::is_le "$avg" "$max_ms"; then
local raw="≀ ${max_ms}"
local padded
padded=$(printf "%14s" "$raw")
Expand Down
99 changes: 46 additions & 53 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,9 +156,9 @@ function bashunit::coverage::get_tracked_files() {
# Get coverage class (high/medium/low) based on percentage
function bashunit::coverage::get_coverage_class() {
local pct="$1"
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" ]; then
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
echo "high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" ]; then
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
echo "medium"
else
echo "low"
Expand DownExpand Up@@ -610,11 +610,7 @@ function bashunit::coverage::get_hit_lines() {
function bashunit::coverage::compute_file_coverage() {
local file="$1"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local executable=0 hit=0 lineno=0 line line_hits
local -a cv_lines=()
Expand All@@ -628,7 +624,7 @@ function bashunit::coverage::compute_file_coverage() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
line_hits=${hits_by_line[lineno]:-0}
line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}
[ "$line_hits" -gt 0 ] && ((++hit))
done

Expand DownExpand Up@@ -715,6 +711,26 @@ function bashunit::coverage::get_all_line_hits() {
return 0
}

# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by
# line number -> hit count) from get_all_line_hits for $1.
#
# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)"
# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an
# array from a function or pass one by reference, and copying a sparse array
# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would
# corrupt the line-number keys -- so this loader writes one shared global
# instead (return-slot pattern, see bash-style.md). Callers must consume the
# result before the next call; there is no adjacent-call isolation.
declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE
function bashunit::coverage::load_hits_by_line() {
local file="$1"
_BASHUNIT_COVERAGE_HITS_BY_LINE=()
local hl_lineno hl_count
while IFS=: read -r hl_lineno hl_count; do
[ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count
done < <(bashunit::coverage::get_all_line_hits "$file")
}

# Get all test hits for a file in one pass (performance optimization)
# Output format: lineno|test_file:test_function (may have duplicates, one per hit)
function bashunit::coverage::get_all_line_tests() {
Expand DownExpand Up@@ -1012,17 +1028,18 @@ function bashunit::coverage::extract_branches() {
}

# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in
# [arm_start..arm_end] has a recorded hit, else 0. Caller must have
# populated the hits_by_line and src_lines arrays in scope; Bash 3.0
# cannot pass arrays into a function. Result is returned via the
# global to avoid a per-arm subshell.
# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from
# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see
# load_hits_by_line); caller must have populated the src_lines array in
# scope -- Bash 3.0 cannot pass arrays into a function. Result is
# returned via the global to avoid a per-arm subshell.
_BASHUNIT_ARM_TAKEN_OUT=0
function bashunit::coverage::_arm_taken() {
local arm_start="$1" arm_end="$2" ln
for ((ln = arm_start; ln <= arm_end; ln++)); do
bashunit::coverage::is_executable_line \
"${src_lines[$((ln - 1))]:-}" "$ln" || continue
if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then
_BASHUNIT_ARM_TAKEN_OUT=1
return
fi
Expand All@@ -1039,11 +1056,7 @@ function bashunit::coverage::_arm_taken() {
function bashunit::coverage::compute_branch_hits() {
local file="$1"

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a src_lines=()
local _sli=0 _sl
Expand DownExpand Up@@ -1218,19 +1231,15 @@ function bashunit::coverage::report_text_uncovered() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a uncovered_lines=()
local _ucount=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -eq 0 ]; then
uncovered_lines[_ucount]="$lineno"
_ucount=$((_ucount + 1))
Expand DownExpand Up@@ -1266,19 +1275,15 @@ function bashunit::coverage::report_text_line_hits() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a hit_specs=()
local _hc=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -gt 0 ]; then
hit_specs[_hc]="${lineno}:${lh}"
_hc=$((_hc + 1))
Expand DownExpand Up@@ -1311,11 +1316,7 @@ function bashunit::coverage::report_text_functions() {
functions_data=$(bashunit::coverage::extract_functions "$file")
[ -z "$functions_data" ] && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a file_lines=()
local _fli=0 _fl
Expand DownExpand Up@@ -1345,7 +1346,7 @@ function bashunit::coverage::report_text_functions() {
bashunit::coverage::is_executable_line \
"${file_lines[$((ln - 1))]:-}" "$ln" || continue
fn_executable=$((fn_executable + 1))
[ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
Expand DownExpand Up@@ -1377,11 +1378,7 @@ function bashunit::coverage::report_lcov() {

echo "SF:$file"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk
# and buffer the matching FNDA lines for emission after, per
Expand All@@ -1396,7 +1393,7 @@ function bashunit::coverage::report_lcov() {

any_hit=0
for ((fln = fn_start; fln <= fn_end; fln++)); do
if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then
any_hit=1
break
fi
Expand DownExpand Up@@ -1436,7 +1433,7 @@ function bashunit::coverage::report_lcov() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done
Expand DownExpand Up@@ -1824,19 +1821,19 @@ EOF
<div class="legend-item">
<span class="legend-color high"></span>
EOF
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High</span>"
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color medium"></span>
EOF
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium</span>"
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color low"></span>
EOF
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low</span>"
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -1921,11 +1918,7 @@ function bashunit::coverage::generate_file_html() {
local uncovered=$((executable - hit))

# Pre-load all line hits into indexed array (performance optimization)
local -a hits_by_line=()
local _ln _cnt
while IFS=: read -r _ln _cnt; do
hits_by_line[_ln]=$_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
Expand DownExpand Up@@ -2206,7 +2199,7 @@ EOF
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}
if [ "$ln_hits" -gt 0 ]; then
((++fn_hit))
fi
Expand DownExpand Up@@ -2269,7 +2262,7 @@ EOF

if bashunit::coverage::is_executable_line "$line" "$lineno"; then
# O(1) lookup from pre-loaded array
local hits=${hits_by_line[$lineno]:-0}
local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}

if [ "$hits" -gt 0 ]; then
row_class="covered"
Expand Down
21 changes: 8 additions & 13 deletions src/main.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,14 @@ function bashunit::main::validate_config_or_exit() {
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_MIN}" "BASHUNIT_COVERAGE_MIN (--coverage-min)"
fi
# Env-only (no CLI flag). Compared with `[ -ge ]` in
# bashunit::coverage::get_coverage_class, which errors instead of returning
# false on a non-integer operand, leaking a raw shell error into the
# coverage report and silently mis-bucketing every file's class (#879).
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" "BASHUNIT_COVERAGE_THRESHOLD_LOW"
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" "BASHUNIT_COVERAGE_THRESHOLD_HIGH"

if [ -n "${BASHUNIT_SEED:-}" ]; then
bashunit::main::require_non_negative_int_or_exit "${BASHUNIT_SEED}" "BASHUNIT_SEED (--seed)"
Expand DownExpand Up@@ -1168,7 +1176,6 @@ function bashunit::main::exec_assert() {

local assert_fn=$original_assert_fn

# Check if the function exists
if ! type "$assert_fn" >/dev/null 2>&1; then
assert_fn="assert_$assert_fn"
if ! type "$assert_fn" >/dev/null 2>&1; then
Expand All@@ -1187,14 +1194,12 @@ function bashunit::main::exec_assert() {
exit 1
fi

# Get the last argument safely by calculating the array length
local last_index=$((args_count - 1))
local last_arg="${args[$last_index]}"
local output=""
local inner_exit_code=0
local bashunit_exit_code=0

# Handle different assert_* functions
case "$assert_fn" in
assert_exit_code)
output=$(bashunit::main::handle_assert_exit_code "$last_arg")
Expand All@@ -1213,10 +1218,8 @@ function bashunit::main::exec_assert() {
assert_fn="assert_same"
fi

# Set a friendly test title for CLI assert command output
bashunit::state::set_test_title "assert ${original_assert_fn#assert_}"

# Run the assertion function and write into stderr
"$assert_fn" "${args[@]}" 1>&2
bashunit_exit_code=$?

Expand DownExpand Up@@ -1258,34 +1261,29 @@ function bashunit::main::exec_multi_assert() {
local cmd="$1"
shift

# Require at least one assertion
if [ $# -lt 1 ]; then
printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
printf "Usage: bashunit assert \"<command>\" <assertion1> <arg1> [<assertion2> <arg2>...]\n" 1>&2
return 1
fi

# Check that assertions come in pairs (assertion + arg)
if [ $# -lt 2 ] || [ $(($# % 2)) -ne 0 ]; then
local assertion_name="${1:-}"
printf "%sError: Missing argument for assertion '%s'.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
return 1
fi

# Execute command and capture output + exit code
local stdout
local cmd_exit_code
stdout=$(eval "$cmd" 2>&1)
cmd_exit_code=$?

# Print stdout for user visibility
if [ -n "$stdout" ]; then
echo "$stdout" 1>&1
fi

# Parse and execute assertions in pairs
local overall_result=0
while [ $# -gt 0 ]; do
local assertion_name="$1"
Expand All@@ -1299,7 +1297,6 @@ function bashunit::main::exec_multi_assert() {

shift 2

# Resolve assertion function name
local assert_fn="$assertion_name"
if ! type "$assert_fn" &>/dev/null; then
assert_fn="assert_$assertion_name"
Expand All@@ -1310,10 +1307,8 @@ function bashunit::main::exec_multi_assert() {
fi
fi

# Set test title for this assertion
bashunit::state::set_test_title "assert ${assertion_name#assert_}"

# Execute assertion with appropriate argument
if bashunit::main::is_exit_code_assertion "$assertion_name"; then
# Exit code assertion: pass expected value and captured exit code
"$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@
- 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
- `BASHUNIT_COVERAGE_THRESHOLD_LOW`/`BASHUNIT_COVERAGE_THRESHOLD_HIGH` (env-only, no CLI flag) now validate as non-negative integers like the other numeric settings. They are compared with `[ -ge ]` in the coverage class lookup, which errors instead of returning false on a non-integer value: a bad threshold leaked a raw `integer expression expected` into the coverage report and silently mis-bucketed every file's high/medium/low class
- `@max_ms` benchmark annotations with a decimal value (e.g. `@max_ms=100.5`) no longer leak a raw `integer expression expected` into the results table and always render as failing (`>`) regardless of the actual average; the threshold comparison now tolerates fractional operands like the average itself already could
- `bashunit assert <name>` with no arguments now reports a clear error and exits non-zero. It used to compute a `-1` array index, which Bash 3.x rejects, so it leaked a raw `bad array subscript` and still exited `0` β€” a malformed standalone assertion in a deploy script reported success (#877)
- A missing `--env`/`--boot` bootstrap file is now an error instead of a green run that tested nothing: the file used to be sourced unchecked, so a typo'd path leaked a raw `No such file or directory`, skipped the entire suite and still exited `0`. The report options (`--report-json`, `--log-junit`, `--report-tap`, `--report-html`, `--log-gha`) are likewise checked before the run instead of failing silently after it β€” an unwritable destination produced a passing run, no report on disk, and exit `0`. `--seed` now validates as a non-negative integer like the other numeric options (#875)
- The numeric options (`--jobs`, `--retry`, `--test-timeout`, `--coverage-min`) and `--output` now reject an invalid value with a clear error and a non-zero exit, instead of dropping the setting. `--jobs abc` was the worst case: `[ n -lt abc ]` errors rather than returning false, so the Bash 3.x job-slot poll never reached its `break` and the run **hung**, while Bash 4.3+ silently ignored the concurrency limit. `--coverage-min abc` leaked a raw `[: abc: integer expression expected` and still exited `0`, so a CI job never enforced its threshold. Validation runs on the resolved configuration, so it covers the `BASHUNIT_*` environment variables too (#873)
Expand Down
2 changes: 1 addition & 1 deletion src/benchmark.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,7 +143,7 @@ function bashunit::benchmark::print_results() {
continue
fi

if [ "$avg" -le "$max_ms" ]; then
if bashunit::math::is_le "$avg" "$max_ms"; then
local raw="≀ ${max_ms}"
local padded
padded=$(printf "%14s" "$raw")
Expand Down
99 changes: 46 additions & 53 deletions src/coverage.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,9 +156,9 @@ function bashunit::coverage::get_tracked_files() {
# Get coverage class (high/medium/low) based on percentage
function bashunit::coverage::get_coverage_class() {
local pct="$1"
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" ]; then
if [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}" ]; then
echo "high"
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" ]; then
elif [ "$pct" -ge "${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}" ]; then
echo "medium"
else
echo "low"
Expand DownExpand Up@@ -610,11 +610,7 @@ function bashunit::coverage::get_hit_lines() {
function bashunit::coverage::compute_file_coverage() {
local file="$1"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local executable=0 hit=0 lineno=0 line line_hits
local -a cv_lines=()
Expand All@@ -628,7 +624,7 @@ function bashunit::coverage::compute_file_coverage() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
line_hits=${hits_by_line[lineno]:-0}
line_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}
[ "$line_hits" -gt 0 ] && ((++hit))
done

Expand DownExpand Up@@ -715,6 +711,26 @@ function bashunit::coverage::get_all_line_hits() {
return 0
}

# Populates the shared _BASHUNIT_COVERAGE_HITS_BY_LINE array (sparse, keyed by
# line number -> hit count) from get_all_line_hits for $1.
#
# Seven call sites used to repeat this "while IFS=: read ... done < <(get_all_line_hits)"
# parse loop into their own `local -a hits_by_line`. Bash 3.0 cannot return an
# array from a function or pass one by reference, and copying a sparse array
# with `local -a x=("${src[@]}")` silently renumbers it from 0, which would
# corrupt the line-number keys -- so this loader writes one shared global
# instead (return-slot pattern, see bash-style.md). Callers must consume the
# result before the next call; there is no adjacent-call isolation.
declare -a _BASHUNIT_COVERAGE_HITS_BY_LINE
function bashunit::coverage::load_hits_by_line() {
local file="$1"
_BASHUNIT_COVERAGE_HITS_BY_LINE=()
local hl_lineno hl_count
while IFS=: read -r hl_lineno hl_count; do
[ -n "$hl_lineno" ] && _BASHUNIT_COVERAGE_HITS_BY_LINE[hl_lineno]=$hl_count
done < <(bashunit::coverage::get_all_line_hits "$file")
}

# Get all test hits for a file in one pass (performance optimization)
# Output format: lineno|test_file:test_function (may have duplicates, one per hit)
function bashunit::coverage::get_all_line_tests() {
Expand DownExpand Up@@ -1012,17 +1028,18 @@ function bashunit::coverage::extract_branches() {
}

# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in
# [arm_start..arm_end] has a recorded hit, else 0. Caller must have
# populated the hits_by_line and src_lines arrays in scope; Bash 3.0
# cannot pass arrays into a function. Result is returned via the
# global to avoid a per-arm subshell.
# [arm_start..arm_end] has a recorded hit, else 0. Reads hit counts from
# the shared _BASHUNIT_COVERAGE_HITS_BY_LINE global (see
# load_hits_by_line); caller must have populated the src_lines array in
# scope -- Bash 3.0 cannot pass arrays into a function. Result is
# returned via the global to avoid a per-arm subshell.
_BASHUNIT_ARM_TAKEN_OUT=0
function bashunit::coverage::_arm_taken() {
local arm_start="$1" arm_end="$2" ln
for ((ln = arm_start; ln <= arm_end; ln++)); do
bashunit::coverage::is_executable_line \
"${src_lines[$((ln - 1))]:-}" "$ln" || continue
if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ]; then
_BASHUNIT_ARM_TAKEN_OUT=1
return
fi
Expand All@@ -1039,11 +1056,7 @@ function bashunit::coverage::_arm_taken() {
function bashunit::coverage::compute_branch_hits() {
local file="$1"

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a src_lines=()
local _sli=0 _sl
Expand DownExpand Up@@ -1218,19 +1231,15 @@ function bashunit::coverage::report_text_uncovered() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a uncovered_lines=()
local _ucount=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -eq 0 ]; then
uncovered_lines[_ucount]="$lineno"
_ucount=$((_ucount + 1))
Expand DownExpand Up@@ -1266,19 +1275,15 @@ function bashunit::coverage::report_text_line_hits() {
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a hit_specs=()
local _hc=0
local lineno=0 line
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
if [ "$lh" -gt 0 ]; then
hit_specs[_hc]="${lineno}:${lh}"
_hc=$((_hc + 1))
Expand DownExpand Up@@ -1311,11 +1316,7 @@ function bashunit::coverage::report_text_functions() {
functions_data=$(bashunit::coverage::extract_functions "$file")
[ -z "$functions_data" ] && continue

local -a hits_by_line=()
local _hl_ln _hl_cnt
while IFS=: read -r _hl_ln _hl_cnt; do
[ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

local -a file_lines=()
local _fli=0 _fl
Expand DownExpand Up@@ -1345,7 +1346,7 @@ function bashunit::coverage::report_text_functions() {
bashunit::coverage::is_executable_line \
"${file_lines[$((ln - 1))]:-}" "$ln" || continue
fn_executable=$((fn_executable + 1))
[ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
Expand DownExpand Up@@ -1377,11 +1378,7 @@ function bashunit::coverage::report_lcov() {

echo "SF:$file"

local -a hits_by_line=()
local hit_lineno hit_count
while IFS=: read -r hit_lineno hit_count; do
[ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk
# and buffer the matching FNDA lines for emission after, per
Expand All@@ -1396,7 +1393,7 @@ function bashunit::coverage::report_lcov() {

any_hit=0
for ((fln = fn_start; fln <= fn_end; fln++)); do
if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$fln]:-0}" -gt 0 ]; then
any_hit=1
break
fi
Expand DownExpand Up@@ -1436,7 +1433,7 @@ function bashunit::coverage::report_lcov() {
((++lineno))
bashunit::coverage::is_executable_line "$line" "$lineno" || continue
((++executable))
local lh="${hits_by_line[$lineno]:-0}"
local lh="${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}"
[ "$lh" -gt 0 ] && ((++hit))
echo "DA:${lineno},${lh}"
done
Expand DownExpand Up@@ -1824,19 +1821,19 @@ EOF
<div class="legend-item">
<span class="legend-color high"></span>
EOF
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High</span>"
echo " <span>β‰₯${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% High</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color medium"></span>
EOF
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium</span>"
echo " <span>${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH}% Medium</span>"
cat <<'EOF'
</div>
<div class="legend-item">
<span class="legend-color low"></span>
EOF
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low</span>"
echo " <span>&lt;${BASHUNIT_COVERAGE_THRESHOLD_LOW:-$_BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_LOW}% Low</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -1921,11 +1918,7 @@ function bashunit::coverage::generate_file_html() {
local uncovered=$((executable - hit))

# Pre-load all line hits into indexed array (performance optimization)
local -a hits_by_line=()
local _ln _cnt
while IFS=: read -r _ln _cnt; do
hits_by_line[_ln]=$_cnt
done < <(bashunit::coverage::get_all_line_hits "$file")
bashunit::coverage::load_hits_by_line "$file"

# Pre-load all file lines into indexed array (avoids sed per line)
local -a file_lines=()
Expand DownExpand Up@@ -2206,7 +2199,7 @@ EOF
ln_content="${file_lines[$((ln - 1))]:-}"
if bashunit::coverage::is_executable_line "$ln_content" "$ln"; then
((++fn_executable))
local ln_hits=${hits_by_line[$ln]:-0}
local ln_hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}
if [ "$ln_hits" -gt 0 ]; then
((++fn_hit))
fi
Expand DownExpand Up@@ -2269,7 +2262,7 @@ EOF

if bashunit::coverage::is_executable_line "$line" "$lineno"; then
# O(1) lookup from pre-loaded array
local hits=${hits_by_line[$lineno]:-0}
local hits=${_BASHUNIT_COVERAGE_HITS_BY_LINE[$lineno]:-0}

if [ "$hits" -gt 0 ]; then
row_class="covered"
Expand Down
21 changes: 8 additions & 13 deletions src/main.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,14 @@ function bashunit::main::validate_config_or_exit() {
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_MIN}" "BASHUNIT_COVERAGE_MIN (--coverage-min)"
fi
# Env-only (no CLI flag). Compared with `[ -ge ]` in
# bashunit::coverage::get_coverage_class, which errors instead of returning
# false on a non-integer operand, leaking a raw shell error into the
# coverage report and silently mis-bucketing every file's class (#879).
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}" "BASHUNIT_COVERAGE_THRESHOLD_LOW"
bashunit::main::require_non_negative_int_or_exit \
"${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}" "BASHUNIT_COVERAGE_THRESHOLD_HIGH"

if [ -n "${BASHUNIT_SEED:-}" ]; then
bashunit::main::require_non_negative_int_or_exit "${BASHUNIT_SEED}" "BASHUNIT_SEED (--seed)"
Expand DownExpand Up@@ -1168,7 +1176,6 @@ function bashunit::main::exec_assert() {

local assert_fn=$original_assert_fn

# Check if the function exists
if ! type "$assert_fn" >/dev/null 2>&1; then
assert_fn="assert_$assert_fn"
if ! type "$assert_fn" >/dev/null 2>&1; then
Expand All@@ -1187,14 +1194,12 @@ function bashunit::main::exec_assert() {
exit 1
fi

# Get the last argument safely by calculating the array length
local last_index=$((args_count - 1))
local last_arg="${args[$last_index]}"
local output=""
local inner_exit_code=0
local bashunit_exit_code=0

# Handle different assert_* functions
case "$assert_fn" in
assert_exit_code)
output=$(bashunit::main::handle_assert_exit_code "$last_arg")
Expand All@@ -1213,10 +1218,8 @@ function bashunit::main::exec_assert() {
assert_fn="assert_same"
fi

# Set a friendly test title for CLI assert command output
bashunit::state::set_test_title "assert ${original_assert_fn#assert_}"

# Run the assertion function and write into stderr
"$assert_fn" "${args[@]}" 1>&2
bashunit_exit_code=$?

Expand DownExpand Up@@ -1258,34 +1261,29 @@ function bashunit::main::exec_multi_assert() {
local cmd="$1"
shift

# Require at least one assertion
if [ $# -lt 1 ]; then
printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
printf "Usage: bashunit assert \"<command>\" <assertion1> <arg1> [<assertion2> <arg2>...]\n" 1>&2
return 1
fi

# Check that assertions come in pairs (assertion + arg)
if [ $# -lt 2 ] || [ $(($# % 2)) -ne 0 ]; then
local assertion_name="${1:-}"
printf "%sError: Missing argument for assertion '%s'.%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2
return 1
fi

# Execute command and capture output + exit code
local stdout
local cmd_exit_code
stdout=$(eval "$cmd" 2>&1)
cmd_exit_code=$?

# Print stdout for user visibility
if [ -n "$stdout" ]; then
echo "$stdout" 1>&1
fi

# Parse and execute assertions in pairs
local overall_result=0
while [ $# -gt 0 ]; do
local assertion_name="$1"
Expand All@@ -1299,7 +1297,6 @@ function bashunit::main::exec_multi_assert() {

shift 2

# Resolve assertion function name
local assert_fn="$assertion_name"
if ! type "$assert_fn" &>/dev/null; then
assert_fn="assert_$assertion_name"
Expand All@@ -1310,10 +1307,8 @@ function bashunit::main::exec_multi_assert() {
fi
fi

# Set test title for this assertion
bashunit::state::set_test_title "assert ${assertion_name#assert_}"

# Execute assertion with appropriate argument
if bashunit::main::is_exit_code_assertion "$assertion_name"; then
# Exit code assertion: pass expected value and captured exit code
"$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2
Expand Down
Loading
Loading