diff --git a/.claude/rules/architecture-map.md b/.claude/rules/architecture-map.md index 3817704f..2a34ec90 100644 --- a/.claude/rules/architecture-map.md +++ b/.claude/rules/architecture-map.md @@ -14,7 +14,7 @@ flow of a test run. Line numbers drift; function names are the stable anchors. ``` bashunit (entry) sources all src/*.sh; version gate; early flag scan └─ bashunit::main::cmd_test (main.sh: flag parsing, env exports) - └─ bashunit::runner::load_test_files (runner.sh: the per-file loop) + └─ bashunit::runner::load_test_files (runner/discovery.sh: the per-file loop) ├─ console_header::print_header "Running N tests" — captures │ └─ helper::find_total_tests $() SUBSHELL: sources each file in a │ nested subshell just to count tests @@ -48,7 +48,17 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end). | Module | Owns | |--------|------| | `bashunit` + `main.sh` | entry, subcommand routing, flag parsing, run lifecycle, exit codes, cleanup calls | -| `runner.sh` | file loop, per-test execution, retry/timeout, result parsing, failure context | +| `runner.sh` | aggregator only — sources the `src/runner/` module below | +| `runner/context.sh` | workdir restore, test identity/location exports, title interpolation, capability probes | +| `runner/payload.sh` | the `_BASHUNIT_RUNNER_*_OUT` return slots; encode/decode of the per-test result payload | +| `runner/diagnostics.sh` | runtime-error detection, kill-signal classification, profiling, verbose/file headers | +| `runner/result.sh` | `parse_result{,_sync,_parallel}`, failure source context, failed/skipped/incomplete/risky writers | +| `runner/parallel.sh` | job-slot waiting (`wait -n` or poll), running-job count, spinner | +| `runner/hooks.sh` | set_up/tear_down (test + script scope), hook failure records, mock clearing, EXIT cleanup | +| `runner/provider.sh` | `@data_provider` argument parsing | +| `runner/exec.sh` | `run_test`, the capture-subshell body, retry, timeout watchdog, per-file dispatch | +| `runner/discovery.sh` | `load_test_files` (the per-file loop), `functions_for_script` | +| `runner/bench.sh` | benchmark file loop and bench function dispatch | | `helpers.sh` | discovery (`find_files_recursive`), fn filtering, provider map, duplicate check, ids | | `state.sh` | counters, per-test payload encode/decode, TAP conversion | | `env.sh` | all `BASHUNIT_*` defaults/config files, scratch dirs (`_BASHUNIT_RUN_OUTPUT_DIR` + EXIT-trap cleanup) | diff --git a/.claude/rules/bash-style.md b/.claude/rules/bash-style.md index 43d04ec4..60fd8272 100644 --- a/.claude/rules/bash-style.md +++ b/.claude/rules/bash-style.md @@ -118,7 +118,7 @@ local thing=$_BASHUNIT_PKG_THING_OUT - A dedicated slot per helper (rather than one shared `_BASHUNIT_OUT`) means adjacent or nested calls can't clobber each other. Cheap: globals are free. -Examples in tree: `src/runner.sh` (`_BASHUNIT_RUNNER_FIELD_OUT`, +Examples in tree: `src/runner/payload.sh` (`_BASHUNIT_RUNNER_FIELD_OUT`, `_BASHUNIT_RUNNER_TOTAL_OUT`, `_BASHUNIT_RUNNER_TYPE_OUT`, `_BASHUNIT_RUNNER_OUTPUT_OUT`), `src/coverage.sh` (`_BASHUNIT_BRANCH_ARMS_OUT`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d63f95a..da627626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Changed +- Internal: `src/runner.sh` is split into a `src/runner/` module of ten single-responsibility files behind a `source`-only aggregator. A pure relocation, no behavior change; see [ADR-010](adrs/adr-010-src-module-directories.md) (#924) + ### Fixed - `build.sh` dedupes embedded files by repo-relative path. The previous basename key compared the top-level loop's relative paths against the recursion's absolute ones, so a file reached from two places could be bundled twice in the released binary; it also collided for same-named files in different directories (#923) diff --git a/adrs/adr-010-src-module-directories.md b/adrs/adr-010-src-module-directories.md new file mode 100644 index 00000000..ffe66924 --- /dev/null +++ b/adrs/adr-010-src-module-directories.md @@ -0,0 +1,108 @@ +# Splitting large `src/` files into module directories + +* Status: accepted +* Deciders: Chemaclass +* Date: 2026-07-30 + +Technical Story: https://github.com/TypedDevs/bashunit/issues/924 + +## Context and Problem Statement + +`src/runner.sh` had grown to 2145 lines and 57 functions covering five unrelated +responsibilities: the per-file loop, per-test execution, retry/timeout, result +parsing and failure context. Navigating it meant scrolling, and every change +touched the same file no matter which concern it belonged to. + +`src/` was flat, so the obvious fix — one file per responsibility — would have +added ten more entries to an already-40-file directory. Can we group them into a +directory without changing what the released single-file binary does? + +## Decision Drivers + +* The distributable is a single concatenated bash script; its execution order + must not change. +* Bash 3.0+ floor, so no clever loading tricks. +* Per-test paths are fork-free and budgeted (`.claude/rules/perf-fork-budget.md`). +* `make test` globs `tests/*/*[tT]est.sh` — exactly one level deep. + +## Considered Options + +* Leave `src/runner.sh` as one file +* Split into flat `src/runner_*.sh` files +* Split into a `src/runner/` directory behind a thin aggregator + +## Decision Outcome + +Chosen option: **`src/runner/` directory behind a thin aggregator**. + +`src/runner.sh` keeps its single `source` line in the `bashunit` entrypoint and +becomes ten `source` lines plus comments. `build.sh` needs no per-module +knowledge: it already recurses into `source` lines, so the module children are +discovered through the aggregator. + +This required fixing `build.sh` first (#923). Its embed dedupe was keyed on a +file's *basename*, which both hid a genuine double-embed (the top-level loop +passes repo-relative paths, the recursion absolute ones, so the two spellings +never matched) and would have collided `src/parallel.sh` with +`src/runner/parallel.sh`. The key is now the repo-relative path. + +**The constraint this buys is worth stating explicitly: an aggregator may contain +only `source` lines and comments.** `build::process_file` emits a file's body and +*then* recurses into its sources, so any statement in an aggregator would run +before its dependencies in the built binary but after them in dev mode. This is +enforced by `test_module_aggregators_hold_only_source_lines_and_comments`. + +Sourcing follows the dependency layering, leaves first: + +``` +context · payload · diagnostics → parallel · hooks · result → provider · exec → discovery · bench +``` + +53 of the 57 functions are leaves; only `load_test_files`, `load_bench_files`, +`run_test` and `parse_result` have callees, so the layering is acyclic. + +### Positive Consequences + +* Each file is 90-540 lines with one responsibility; `src/` root gains a + directory instead of ten files. +* No runtime cost. Cold start is dominated by parse time, not file opens + (measured in #798), and a file split adds no forks — the fork-budget + acceptance tests pass unchanged. +* The pattern generalises: `src/coverage.sh` (2548 lines) is the next candidate. + +### Negative Consequences + +* Return-slot globals now cross file boundaries — `runner/payload.sh` declares + the 13 `_BASHUNIT_RUNNER_*_OUT` slots that `runner/exec.sh` reads. ShellCheck + can no longer see both ends, so `runner/exec.sh` needs one scoped `SC2154` + disable for the `exit_code` assigned inside an EXIT trap body and read by + `cleanup_on_exit` in `runner/hooks.sh`. +* One more indirection when grepping: `bashunit::runner::*` now spans ten files. + +## Pros and Cons of the Options + +### Leave `src/runner.sh` as one file + +* Good, because zero risk and zero churn. +* Bad, because the file had five responsibilities and no seam to test them apart. + +### Flat `src/runner_*.sh` files + +* Good, because it needs no `build.sh` change at all. +* Bad, because it grows the flat `src/` root by ten entries and encodes the + grouping in a filename prefix rather than in the directory structure. +* Bad, because it does not generalise — `src/coverage.sh` would add nine more. + +### `src/runner/` directory behind an aggregator + +* Good, because it mirrors the existing `src/assertions.sh` → `src/assert_*.sh` + aggregator precedent, and `src/dev/debug.sh` already proved `src/` can nest. +* Good, because `build.sh` discovers children through recursion, so adding a + module file needs no build change. +* Bad, because it required fixing the build's dedupe key first (#923). + +## Links + +* Enabled by [#923](https://github.com/TypedDevs/bashunit/issues/923) — repo-relative embed markers +* Tests stay flat: `make test` globs one level, so `tests/unit/runner_*_test.sh`, + never `tests/unit/runner/` diff --git a/src/coverage.sh b/src/coverage.sh index 3a61dde2..c72bdebb 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -170,8 +170,8 @@ function bashunit::coverage::resolve_engine() { esac } -# Name kept from the trap-only era: this is the seam runner.sh already calls -# around every test body and lifecycle hook. +# Name kept from the trap-only era: this is the seam runner/exec.sh and +# runner/hooks.sh already call around every test body and lifecycle hook. function bashunit::coverage::enable_trap() { if ! bashunit::env::is_coverage_enabled; then return 0 diff --git a/src/runner.sh b/src/runner.sh index 463adcf0..50d26da4 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -1,2145 +1,18 @@ #!/usr/bin/env bash -# shellcheck disable=SC2155 -## -# Returns to the directory bashunit was started from, undoing any `cd` a test -# file performed in `set_up_before_script` (#532). +# Aggregator for the src/runner/ module: only `source` lines and comments belong +# here. build.sh emits a file's body before recursing into its `source` lines, so +# any statement here would run before its dependencies in the built binary. # -# A failure here is not recoverable: every later test file is discovered and -# sourced through a path relative to this directory, so silently staying put -# would drop the remaining files from the run without a single error. Abort -# loudly instead. -# -# Arguments: $1 (optional) directory to restore, defaults to BASHUNIT_WORKING_DIR -## -function bashunit::runner::restore_workdir() { - local target="${1:-${BASHUNIT_WORKING_DIR:-}}" - if cd "$target" 2>/dev/null; then - return 0 - fi - - printf "%sError: cannot restore the working directory '%s'. Aborting run.%s\n" \ - "${_BASHUNIT_COLOR_FAILED:-}" "$target" "${_BASHUNIT_COLOR_DEFAULT:-}" >&2 - exit 1 -} - -## -# Whether the running Bash has a reliable `set -o pipefail`. Bash 3.0 shipped a -# broken pipefail (a failing pipeline can wrongly report success), which makes -# `--strict` unsound; on 3.0 we fall back to `set -eu` without pipefail. -# Returns: 0 when pipefail is reliable (Bash >= 3.1), 1 otherwise. -## -function bashunit::runner::_supports_reliable_pipefail() { - if [ "${BASH_VERSINFO[0]:-0}" -gt 3 ]; then - return 0 - fi - [ "${BASH_VERSINFO[0]:-0}" -eq 3 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 1 ] -} - -# Caches BASHUNIT_COVERAGE into _BASHUNIT_COVERAGE_ON ("1"|"0") so hot-path checks -# avoid a function dispatch per call. Call once after arg parsing; tests that -# toggle BASHUNIT_COVERAGE mid-run must call this again to refresh. -function bashunit::runner::sync_coverage_flag() { - if [ "${BASHUNIT_COVERAGE-}" = "true" ]; then - _BASHUNIT_COVERAGE_ON=1 - else - _BASHUNIT_COVERAGE_ON=0 - fi -} - -function bashunit::runner::source_login_shell_profiles() { - # shellcheck disable=SC1091 - [ -f /etc/profile ] && source /etc/profile 2>/dev/null || true - # shellcheck disable=SC1090 - [ -f ~/.bash_profile ] && source ~/.bash_profile 2>/dev/null || true - # shellcheck disable=SC1090 - [ -f ~/.bash_login ] && source ~/.bash_login 2>/dev/null || true - # shellcheck disable=SC1090 - [ -f ~/.profile ] && source ~/.profile 2>/dev/null || true -} - -function bashunit::runner::export_test_identity() { - local test_file=$1 - local fn_name=$2 - bashunit::helper::generate_id "$fn_name" - export BASHUNIT_CURRENT_TEST_ID="$_BASHUNIT_HELPER_ID_OUT" - bashunit::runner::resolve_test_location "$test_file" "$fn_name" - export _BASHUNIT_TEST_LOCATION - if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then - export _BASHUNIT_COVERAGE_CURRENT_TEST_FILE="$test_file" - export _BASHUNIT_COVERAGE_CURRENT_TEST_FN="$fn_name" - fi -} - -## -# Resolves ":" for a test function and writes it into the -# global _BASHUNIT_TEST_LOCATION, using `declare -F` under `extdebug` to read -# the definition line. Falls back to just the file path when the line cannot be -# determined. Bash 3.0+ compatible. Writes a global slot (no extra subshell). -# Arguments: $1 test file, $2 function name -## -function bashunit::runner::resolve_test_location() { - local test_file=$1 - local fn_name=$2 - - # Enable extdebug only inside the command-substitution subshell so it never - # leaks into the parent shell — globally toggling extdebug interferes with - # `set -e`/DEBUG-trap behavior under --strict. - local def line="" - def="$(shopt -s extdebug; declare -F "$fn_name" 2>/dev/null)" || true - - # `declare -F` (with extdebug) prints " ". - if [ -n "$def" ]; then - line=${def#* } - line=${line%% *} - fi - - if [ -n "$line" ]; then - _BASHUNIT_TEST_LOCATION="${test_file}:${line}" - else - _BASHUNIT_TEST_LOCATION="$test_file" - fi -} - -# Writes the interpolated test-function name into _BASHUNIT_RUNNER_INTERP_OUT. -# Arguments: $1 fn_name, $@ test arguments -function bashunit::runner::apply_interpolated_title() { - local fn_name=$1 - shift - - # Only "::N::"-style names interpolate; skip the capture fork for the rest. - case "$fn_name" in - *::*) ;; - *) - bashunit::state::reset_current_test_interpolated_function_name - _BASHUNIT_RUNNER_INTERP_OUT=$fn_name - return - ;; - esac - - local interpolated - interpolated="$(bashunit::helper::interpolate_function_name "$fn_name" "$@")" - if [ "$interpolated" != "$fn_name" ]; then - bashunit::state::set_current_test_interpolated_function_name "$interpolated" - else - bashunit::state::reset_current_test_interpolated_function_name - fi - _BASHUNIT_RUNNER_INTERP_OUT=$interpolated -} - -# Hot-path result helpers below return their value via a dedicated global slot -# (`_BASHUNIT_RUNNER_*_OUT`) instead of stdout. This avoids the per-test -# `$(...)` subshell capture that dominated the result-parsing hot path. Callers -# invoke the helper and immediately read the slot: -# -# bashunit::runner::extract_subshell_type "$subshell_output" -# type=$_BASHUNIT_RUNNER_TYPE_OUT -# -# A dedicated slot per helper (rather than one shared slot) means nested or -# adjacent calls cannot clobber each other and callers don't need to copy out -# before every other helper runs. -_BASHUNIT_RUNNER_FIELD_OUT="" -_BASHUNIT_RUNNER_TOTAL_OUT="" -_BASHUNIT_RUNNER_TYPE_OUT="" -_BASHUNIT_RUNNER_OUTPUT_OUT="" -_BASHUNIT_RUNNER_INTERP_OUT="" -_BASHUNIT_RUNNER_COUNTS_FAILED_OUT=0 -_BASHUNIT_RUNNER_COUNTS_PASSED_OUT=0 -_BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT=0 -_BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT=0 -_BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT=0 -_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT=0 -_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" -_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="" -# Per-suite ordinal for naming a parallel worker's `.result` file. Set by -# call_test_functions (single-threaded dispatch) just before each backgrounded -# run_test; the fork inherits the value, so every test in a file gets a unique, -# collision-free name in its per-suite dir without forking mktemp/mv. -_BASHUNIT_RUNNER_RESULT_ORDINAL=0 -# Suffix appended to a passed-test line when it only passed after retrying. -_BASHUNIT_RETRY_NOTE="" - -# Writes the value of an encoded field (##KEY=value##) into _BASHUNIT_RUNNER_FIELD_OUT. -# Arguments: $1 test_execution_result, $2 key -function bashunit::runner::extract_encoded_field() { - local test_execution_result=$1 - local key=$2 - local marker="##${key}=" - case "$test_execution_result" in - *"$marker"*) - local rest="${test_execution_result#*"$marker"}" - _BASHUNIT_RUNNER_FIELD_OUT="${rest%%##*}" - ;; - *) _BASHUNIT_RUNNER_FIELD_OUT="" ;; - esac -} - -# Writes the sum of all ASSERTIONS_* counters into _BASHUNIT_RUNNER_TOTAL_OUT. -# Arguments: $1 test_execution_result -function bashunit::runner::compute_total_assertions() { - local test_execution_result=$1 - local failed passed skipped incomplete snapshot - failed="${test_execution_result##*##ASSERTIONS_FAILED=}" - failed="${failed%%##*}" - passed="${test_execution_result##*##ASSERTIONS_PASSED=}" - passed="${passed%%##*}" - skipped="${test_execution_result##*##ASSERTIONS_SKIPPED=}" - skipped="${skipped%%##*}" - incomplete="${test_execution_result##*##ASSERTIONS_INCOMPLETE=}" - incomplete="${incomplete%%##*}" - snapshot="${test_execution_result##*##ASSERTIONS_SNAPSHOT=}" - snapshot="${snapshot%%##*}" - # A result that never reached the payload (a SIGKILLed subshell, raw stderr) - # leaves every ##KEY= strip a no-op, so these fields hold arbitrary text. That - # text is not "0" to `$(( ))`: it is a fatal arithmetic syntax error that - # aborts the run. One `case` over the concatenation costs no fork and keeps - # the happy path (all digits, or empty for an absent counter) untouched. - case "$failed$passed$skipped$incomplete$snapshot" in - *[!0-9]*) failed=0 passed=0 skipped=0 incomplete=0 snapshot=0 ;; - esac - local total - total=$((failed + passed + skipped)) - total=$((total + incomplete + snapshot)) - _BASHUNIT_RUNNER_TOTAL_OUT=$total -} - -# Writes the subshell type marker (text inside leading [...]) into _BASHUNIT_RUNNER_TYPE_OUT. -# Arguments: $1 subshell_output -function bashunit::runner::extract_subshell_type() { - local subshell_output=$1 - local type="${subshell_output%%]*}" - _BASHUNIT_RUNNER_TYPE_OUT="${type#[}" -} - -# Writes the subshell output (minus the leading [type] marker, with embedded -# status markers replaced by newlines) into _BASHUNIT_RUNNER_OUTPUT_OUT. -# Arguments: $1 subshell_output -function bashunit::runner::format_subshell_output() { - local subshell_output=$1 - local line="${subshell_output#*]}" - line=${line//\[failed\]/$'\n'} - line=${line//\[skipped\]/$'\n'} - line=${line//\[incomplete\]/$'\n'} - _BASHUNIT_RUNNER_OUTPUT_OUT=$line -} - -## -# Appends a profiling record (duration, test name, file) to PROFILE_OUTPUT_PATH. -# Uses a tab-separated, append-only line so it aggregates correctly across the -# subshells spawned by parallel runs. -# Arguments: $1 duration (ms), $2 test name, $3 test file -## -function bashunit::runner::record_profile() { - local duration=$1 - local test_name=$2 - local test_file=$3 - printf '%s\t%s\t%s\n' "$duration" "$test_name" "$test_file" >>"$PROFILE_OUTPUT_PATH" -} - -## -# Honours --stop-on-failure once a test has been recorded as failed. A parallel -# worker raises the shared flag file (the dispatcher checks it between tests) -# rather than exiting, since exiting would only kill the worker. A sequential -# run exits with EXIT_CODE_STOP_ON_FAILURE, which main.sh's EXIT trap turns -# into the final summary. No-op when the flag is off. -## -function bashunit::runner::halt_if_stop_on_failure() { - bashunit::env::is_stop_on_failure_enabled || return 0 - - if bashunit::parallel::is_enabled; then - bashunit::parallel::mark_stop_on_failure - else - exit "$EXIT_CODE_STOP_ON_FAILURE" - fi -} - -# Writes the detected runtime-error message (empty when none) into -# _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT. Return-slot form avoids a per-test fork -# on the hot path (#764). -# Arguments: $1 runtime_output -function bashunit::runner::detect_runtime_error() { - local runtime_output=$1 - _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" - case "$runtime_output" in - *"command not found"* | *"unbound variable"* | *"permission denied"* | \ - *"no such file or directory"* | *"syntax error"* | *"bad substitution"* | \ - *"division by 0"* | *"cannot allocate memory"* | *"bad file descriptor"* | \ - *"segmentation fault"* | *"illegal option"* | *"argument list too long"* | \ - *"readonly variable"* | *"missing keyword"* | *"killed"* | \ - *"cannot execute binary file"* | *"invalid arithmetic operator"* | \ - *"ambiguous redirect"* | *"integer expression expected"* | \ - *"too many arguments"* | *"value too great"* | \ - *"not a valid identifier"* | *"unexpected EOF"*) - local runtime_error="${runtime_output#*: }" - _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="${runtime_error//$'\n'/}" - ;; - esac -} - -## -# Maps a process exit code to a human-readable description when it indicates the -# test was killed by a signal (128 + signal) or timed out. Returns an empty -# string for ordinary exit codes. Bash 3.0+ compatible. -# Arguments: $1 exit code -## -function bashunit::runner::classify_kill_signal() { - local code=$1 - - case "$code" in - 124) printf 'Timed out (killed by `timeout`)' ;; - 130) printf 'Interrupted (SIGINT)' ;; - 137) printf 'Killed (SIGKILL — out of memory or forced termination)' ;; - 143) printf 'Terminated (SIGTERM — e.g. a timeout)' ;; - *) - # Generic "killed by signal N" for other 128+N codes (signals 1..64) - case "$code" in - '' | *[!0-9]*) return 0 ;; - esac - if [ "$code" -gt 128 ] && [ "$code" -le 192 ]; then - printf 'Killed by signal %s' "$((code - 128))" - fi - ;; - esac -} - -function bashunit::runner::print_verbose_test_summary() { - local test_file=$1 - local fn_name=$2 - local duration=$3 - local test_execution_result=$4 - - if bashunit::env::is_simple_output_enabled; then - echo "" - fi - - printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '=' - printf "%s\n" "File: $test_file" - printf "%s\n" "Function: $fn_name" - printf "%s\n" "Duration: $duration ms" - local raw_text=${test_execution_result%%##ASSERTIONS_*} - [ -n "$raw_text" ] && printf "%s" "Raw text: $raw_text" - printf "%s\n" "##ASSERTIONS_${test_execution_result#*##ASSERTIONS_}" - printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '-' -} - -# Returns 0 when this Bash supports `wait -n` (Bash 4.3+), 1 otherwise. -function bashunit::runner::_supports_wait_n() { - local major="${BASH_VERSINFO[0]:-0}" - local minor="${BASH_VERSINFO[1]:-0}" - if [ "$major" -gt 4 ]; then - return 0 - fi - if [ "$major" -eq 4 ] && [ "$minor" -ge 3 ]; then - return 0 - fi - return 1 -} - -_BASHUNIT_RUNNER_RUNNING_JOBS_OUT=0 - -# Counts running background jobs into _BASHUNIT_RUNNER_RUNNING_JOBS_OUT. `jobs -pr` -# still needs one command substitution, but the line count is pure-bash, so this -# drops the extra `wc` fork per poll iteration on the parallel hot path (#761). -function bashunit::runner::_count_running_jobs() { - local running - running=$(jobs -pr) - if [ -z "$running" ]; then - _BASHUNIT_RUNNER_RUNNING_JOBS_OUT=0 - return - fi - local newlines="${running//[!$'\n']/}" - _BASHUNIT_RUNNER_RUNNING_JOBS_OUT=$((${#newlines} + 1)) -} - -function bashunit::runner::wait_for_job_slot() { - local max_jobs="${BASHUNIT_PARALLEL_JOBS:-0}" - if [ "$max_jobs" -le 0 ]; then - return 0 - fi - - if bashunit::runner::_supports_wait_n; then - # Bash 4.3+: block until any child exits. No polling, no sleep latency. - bashunit::runner::_count_running_jobs - while [ "$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT" -ge "$max_jobs" ]; do - wait -n 2>/dev/null || break - bashunit::runner::_count_running_jobs - done - return 0 - fi - - # Bash 3.x fallback: adaptive poll starting at 50ms, growing to 200ms to - # reduce `jobs -r` overhead on long-running tests while staying responsive. - local delay="0.05" - local iterations=0 - while true; do - bashunit::runner::_count_running_jobs - if [ "$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT" -lt "$max_jobs" ]; then - break - fi - sleep "$delay" - iterations=$((iterations + 1)) - if [ "$iterations" -eq 4 ]; then - delay="0.1" - elif [ "$iterations" -eq 20 ]; then - delay="0.2" - fi - done -} - -function bashunit::runner::load_test_files() { - local filter=$1 - local tag_filter="${2:-}" - local exclude_tag_filter="${3:-}" - shift 3 - local IFS=$' \t\n' - local -a files - files=("$@") - local -a scripts_ids=() - local scripts_ids_count=0 - local -a worker_stderr_paths=() - local -a worker_stderr_owners=() - local worker_stderr_count=0 - - # Randomize file execution order (deterministic for the resolved seed). - if bashunit::env::is_random_order_enabled; then - local -a _shuffled_files=() - local _sf - while IFS= read -r _sf; do - [ -n "$_sf" ] && _shuffled_files[${#_shuffled_files[@]}]=$_sf - done < <(printf '%s\n' "${files[@]+"${files[@]}"}" | bashunit::math::shuffle "$(bashunit::env::seed)") - files=("${_shuffled_files[@]+"${_shuffled_files[@]}"}") - fi - - bashunit::runner::sync_coverage_flag - - # Initialize coverage tracking if enabled - if [ "$_BASHUNIT_COVERAGE_ON" = 1 ]; then - # Auto-discover coverage paths if not explicitly set - if [ -z "$BASHUNIT_COVERAGE_PATHS" ]; then - BASHUNIT_COVERAGE_PATHS=$(bashunit::coverage::auto_discover_paths "${files[@]}") - # Fallback: if auto-discovery yields no paths, track the src/ folder - if [ -z "$BASHUNIT_COVERAGE_PATHS" ]; then - BASHUNIT_COVERAGE_PATHS="src/" - fi - fi - bashunit::coverage::init - fi - - local test_file - for test_file in "${files[@]+"${files[@]}"}"; do - if [ ! -f "$test_file" ]; then - continue - fi - unset BASHUNIT_CURRENT_TEST_ID - bashunit::helper::generate_id "${test_file}" - export BASHUNIT_CURRENT_SCRIPT_ID="$_BASHUNIT_HELPER_ID_OUT" - scripts_ids[scripts_ids_count]="${BASHUNIT_CURRENT_SCRIPT_ID}" - scripts_ids_count=$((scripts_ids_count + 1)) - bashunit::internal_log "Loading file" "$test_file" - # Files are sourced sequentially in this loop (parallel workers fork after), - # so a fixed path in the run dir is safe: `2>` truncates it per file and the - # run-dir cleanup removes it, saving a mktemp and an rm fork per file. - local source_err_file source_err source_status - source_err_file="$_BASHUNIT_RUN_OUTPUT_DIR/source_err" - # shellcheck source=/dev/null - source "$test_file" 2>"$source_err_file" - source_status=$? - # A test file may enable `set -euo pipefail` at its top level; sourcing - # runs that in THIS shell, so a later non-zero status in the loop (e.g. a - # failing set_up_before_script) would kill the whole run mid-suite with no - # summary. Strictness is applied per-test in execute_test_body — reset the - # runner loop to its set +euo invariant (see main.sh exec_tests) (#836). - set +euo pipefail - source_err="" - if [ -s "$source_err_file" ]; then - source_err="$(cat "$source_err_file")" - fi - # A non-zero source status, or a syntax-error line on stderr, means the file - # failed to load. Match the captured stderr with `case` (no grep fork). - local source_failed=false - if [ "$source_status" -ne 0 ]; then - source_failed=true - else - case "$source_err" in - *"syntax error"* | *"unexpected EOF"*) source_failed=true ;; - esac - fi - if [ "$source_failed" = true ]; then - local message="$source_err" - [ -z "$message" ] && message="Failed to source '$test_file' (exit $source_status)" - bashunit::runner::record_file_hook_failure \ - "source" "$test_file" "$message" 1 true - bashunit::runner::clean_set_up_and_tear_down_after_script - bashunit::runner::restore_workdir - continue - fi - # Update function cache after sourcing new test file (compgen is a builtin) - _BASHUNIT_CACHED_ALL_FUNCTIONS=$(compgen -A function) - # Check if any tests match the filter before rendering header or running hooks - local filtered_functions - filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") - local functions_for_script - functions_for_script=$(bashunit::runner::functions_for_script "$test_file" "$filtered_functions") - # Full pre-tag/rerun list: these are unset once the file has been - # processed, whatever subset actually runs (#829). - local _script_fns_to_clean="$functions_for_script" - # Apply tag filtering to the early check as well - if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then - bashunit::helper::build_tags_map "$test_file" - local _early_filtered="" - local _early_fn - for _early_fn in $functions_for_script; do - bashunit::helper::tags_for_function "$_early_fn" - if bashunit::helper::function_matches_tags "$_BASHUNIT_TAGS_OUT" "$tag_filter" "$exclude_tag_filter"; then - _early_filtered="$_early_filtered $_early_fn" - fi - done - functions_for_script="${_early_filtered# }" - fi - # Replay filtering: keep only the functions recorded as failing last run. - if bashunit::rerun::is_enabled && bashunit::rerun::has_entries; then - functions_for_script=$(bashunit::rerun::filter_functions "$test_file" "$functions_for_script") - fi - if [ -z "$functions_for_script" ]; then - bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" - bashunit::runner::clean_set_up_and_tear_down_after_script - bashunit::runner::restore_workdir - continue - fi - # Render header BEFORE set_up_before_script so user sees activity immediately - bashunit::runner::render_running_file_header "$test_file" - # Call hook directly (not with `if !`) to preserve errexit behavior inside the hook - bashunit::runner::run_set_up_before_script "$test_file" - local setup_before_script_status=$? - if [ $setup_before_script_status -ne 0 ]; then - # Count the test functions that couldn't run due to set_up_before_script - # failure and add them as failed (minus 1 since the hook failure already - # counts as 1). Use this file's own function list — scanning the cached - # ALL-functions set would also count fns left over from earlier files - # and inflate the totals (#836). - if [ -n "$functions_for_script" ]; then - # Bash 3.0 compatible: separate declaration and assignment for arrays - local functions_to_run - # shellcheck disable=SC2206 - functions_to_run=($functions_for_script) - local additional_failures=$((${#functions_to_run[@]} - 1)) - local i - for ((i = 0; i < additional_failures; i++)); do - bashunit::state::add_tests_failed - done - fi - # Same cleanup as the success path: without it the file's test functions - # leak into the next iteration's counts and the main shell (#829, #836). - bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" - bashunit::runner::clean_set_up_and_tear_down_after_script - if ! bashunit::parallel::is_enabled; then - bashunit::cleanup_script_temp_files - fi - bashunit::runner::restore_workdir - continue - fi - local _cached_fns="$functions_for_script" - if bashunit::parallel::is_enabled; then - bashunit::runner::wait_for_job_slot - # Capture rather than discard: a worker's stderr cannot be written - # straight to the terminal without shredding the progress line, but - # dropping it made the same run report differently under --parallel - # (#358 added the discard, #864 replaced it with this capture). - local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}" - worker_stderr_paths[worker_stderr_count]="$_worker_stderr" - worker_stderr_owners[worker_stderr_count]="$test_file" - worker_stderr_count=$((worker_stderr_count + 1)) - bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" & - else - bashunit::runner::call_test_functions "$test_file" "$_cached_fns" - fi - bashunit::runner::run_tear_down_after_script "$test_file" - bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" - bashunit::runner::clean_set_up_and_tear_down_after_script - if ! bashunit::parallel::is_enabled; then - bashunit::cleanup_script_temp_files - fi - bashunit::internal_log "Finished file" "$test_file" - bashunit::runner::restore_workdir - done - - if bashunit::parallel::is_enabled; then - wait - bashunit::runner::spinner & - local spinner_pid=$! - bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" - # Kill the spinner once the aggregation finishes - disown "$spinner_pid" 2>/dev/null || true - kill "$spinner_pid" 2>/dev/null || true - printf "\r \r" # Clear the spinner output - - local _stderr_idx=0 - while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do - if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then - bashunit::console_results::print_worker_stderr \ - "${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}" - fi - _stderr_idx=$((_stderr_idx + 1)) - done - - local script_id - for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do - export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}" - bashunit::cleanup_script_temp_files - done - fi -} - -function bashunit::runner::load_bench_files() { - local filter=$1 - shift - local IFS=$' \t\n' - local -a files - files=("$@") - - local bench_file - for bench_file in "${files[@]+"${files[@]}"}"; do - [ -f "$bench_file" ] || continue - unset BASHUNIT_CURRENT_TEST_ID - bashunit::helper::generate_id "${bench_file}" - export BASHUNIT_CURRENT_SCRIPT_ID="$_BASHUNIT_HELPER_ID_OUT" - # shellcheck source=/dev/null - source "$bench_file" - # Reset the loop's shell-mode invariant; a bench file may set -euo at top - # level and sourcing runs that in this shell (see the test loop) (#836). - set +euo pipefail - # Update function cache after sourcing new bench file (compgen is a builtin) - _BASHUNIT_CACHED_ALL_FUNCTIONS=$(compgen -A function) - # Call hook directly (not with `if !`) to preserve errexit behavior inside the hook - bashunit::runner::run_set_up_before_script "$bench_file" - local setup_before_script_status=$? - if [ $setup_before_script_status -ne 0 ]; then - # Count the bench functions that couldn't run due to set_up_before_script failure - # and add them as failed (minus 1 since the hook failure already counts as 1) - local filtered_functions - filtered_functions=$(bashunit::helper::get_functions_to_run "bench" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") - if [ -n "$filtered_functions" ]; then - # Bash 3.0 compatible: separate declaration and assignment for arrays - local functions_to_run - # shellcheck disable=SC2206 - functions_to_run=($filtered_functions) - local additional_failures=$((${#functions_to_run[@]} - 1)) - local i - for ((i = 0; i < additional_failures; i++)); do - bashunit::state::add_tests_failed - done - fi - bashunit::runner::clean_set_up_and_tear_down_after_script - bashunit::cleanup_script_temp_files - bashunit::runner::restore_workdir - continue - fi - bashunit::runner::call_bench_functions "$bench_file" "$filter" - bashunit::runner::run_tear_down_after_script "$bench_file" - bashunit::runner::clean_set_up_and_tear_down_after_script - bashunit::cleanup_script_temp_files - bashunit::runner::restore_workdir - done -} - -function bashunit::runner::spinner() { - # Only show spinner when output is to a terminal - if [ ! -t 1 ]; then - # Not a terminal, just wait silently - while true; do sleep 1; done - return - fi - - # Don't show spinner in no-progress mode - if bashunit::env::is_no_progress_enabled; then - while true; do sleep 1; done - return - fi - - if bashunit::env::is_simple_output_enabled; then - printf "\n" - fi - - local delay=0.1 - local spin_chars="|/-\\" - while true; do - local i - for ((i = 0; i < ${#spin_chars}; i++)); do - printf "\r%s" "${spin_chars:$i:1}" - sleep "$delay" - done - done -} - -function bashunit::runner::functions_for_script() { - local script="$1" - local all_fn_names="$2" - - # Resolve " " for the given names, enabling extdebug only - # inside the capture subshell so the caller's setting is untouched. - local declarations - # shellcheck disable=SC2086 - declarations=$( - shopt -s extdebug - declare -F $all_fn_names 2>/dev/null - ) - - # Keep the functions defined in this script, insertion-sorted by definition - # line. Pure bash: the old `awk | sort | awk` pipeline cost three forks and - # ran twice per file, while a file's function list is small (tens of names). - local -a fns=() - local -a fn_lines=() - local count=0 - local name line file i - while read -r name line file; do - [ "$file" = "$script" ] || continue - i=$count - while [ "$i" -gt 0 ] && [ "${fn_lines[i - 1]}" -gt "$line" ]; do - fns[i]=${fns[i - 1]} - fn_lines[i]=${fn_lines[i - 1]} - i=$((i - 1)) - done - fns[i]=$name - fn_lines[i]=$line - count=$((count + 1)) - done </dev/null; then - # Check if args has elements after eval - args_count=0 - local _tmp arg - for _tmp in ${args+"${args[@]}"}; do args_count=$((args_count + 1)); done - if [ "$args_count" -gt 0 ]; then - # Successfully parsed - remove sentinel if present - local last_idx=$((args_count - 1)) - if [ -z "${args[$last_idx]}" ]; then - unset 'args[$last_idx]' - fi - # Print args and return early - for arg in "${args[@]+"${args[@]}"}"; do - encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" - printf '%s\n' "$encoded_arg" - done - return - fi - fi - - # Fallback: parse args from the input string into an array, respecting quotes and escapes - local i - for ((i = 0; i < ${#input}; i++)); do - local char="${input:$i:1}" - if [ "$escaped" = true ]; then - case "$char" in - t) current_arg="$current_arg"$'\t' ;; - n) current_arg="$current_arg"$'\n' ;; - *) current_arg="$current_arg$char" ;; - esac - escaped=false - elif [ "$char" = "\\" ]; then - escaped=true - elif [ "$in_quotes" = false ]; then - case "$char" in - "$") - # Handle $'...' syntax - if [ "${input:$i:2}" = "$'" ]; then - in_quotes=true - had_quotes=true - quote_char="'" - # Skip the $ - i=$((i + 1)) - else - current_arg="$current_arg$char" - fi - ;; - "'" | '"') - in_quotes=true - had_quotes=true - quote_char="$char" - ;; - " " | $'\t') - # Add if non-empty OR if was quoted (to preserve empty quoted strings like '') - if [ -n "$current_arg" ] || [ "$had_quotes" = true ]; then - args[args_count]="$current_arg" - args_count=$((args_count + 1)) - fi - current_arg="" - had_quotes=false - ;; - *) - current_arg="$current_arg$char" - ;; - esac - elif [ "$char" = "$quote_char" ]; then - in_quotes=false - quote_char="" - else - current_arg="$current_arg$char" - fi - done - args[args_count]="$current_arg" - args_count=$((args_count + 1)) - # Remove all trailing empty strings - while [ "$args_count" -gt 0 ]; do - local last_idx=$((args_count - 1)) - if [ -z "${args[$last_idx]}" ]; then - unset 'args[$last_idx]' - args_count=$((args_count - 1)) - else - break - fi - done - # Print one arg per line to stdout, base64-encoded to preserve newlines in the data - local arg - for arg in ${args+"${args[@]}"}; do - encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" - printf '%s\n' "$encoded_arg" - done -} - -## -# Runs the given test functions of a script (sequentially, or one background -# worker per test under --parallel). -# Arguments: $1 script path, $2 space-separated test function names, already -# filter/tag/rerun-filtered by load_test_files (never empty: the caller skips -# the file when no function survives filtering). -## -function bashunit::runner::call_test_functions() { - local script="$1" - local cached_functions="${2:-}" - local IFS=$' \t\n' - local -a functions_to_run=() - local functions_to_run_count=0 - - local _fn - for _fn in $cached_functions; do - [ -z "$_fn" ] && continue - functions_to_run[functions_to_run_count]="$_fn" - functions_to_run_count=$((functions_to_run_count + 1)) - done - - # Randomize function order within this file. The seed is mixed with a stable - # per-file value (cksum of the path) so different files get different orders - # while staying reproducible for the resolved seed. - if bashunit::env::is_random_order_enabled && [ "$functions_to_run_count" -gt 1 ]; then - local _base _crc _fn_seed - _base=$(bashunit::env::seed) - _crc=$(printf '%s' "$script" | cksum | cut -d' ' -f1) - _fn_seed=$(((_base + _crc) & 2147483647)) - local -a _shuffled_fns=() - local _sfn - while IFS= read -r _sfn; do - [ -n "$_sfn" ] && _shuffled_fns[${#_shuffled_fns[@]}]=$_sfn - done < <(printf '%s\n' "${functions_to_run[@]+"${functions_to_run[@]}"}" | bashunit::math::shuffle "$_fn_seed") - functions_to_run=("${_shuffled_fns[@]+"${_shuffled_fns[@]}"}") - functions_to_run_count=${#functions_to_run[@]} - fi - - if [ "$functions_to_run_count" -le 0 ]; then - return - fi - - bashunit::helper::check_duplicate_functions "$script" || true - - local -a provider_data=() - local provider_data_count=0 - local -a parsed_data=() - local parsed_data_count=0 - # Monotonic within this file; names each parallel worker's .result file. - local _test_ordinal=0 - - # Scan the file once; per-test provider lookups below are pure-bash (#763). - # The same pass also detects the no-parallel-tests opt-out (#774). - bashunit::helper::build_provider_map "$script" - - local allow_test_parallel=true - if [ "$_BASHUNIT_PROVIDER_MAP_NO_PARALLEL" = true ]; then - allow_test_parallel=false - fi - - # Pre-create the file's result dir before spawning test workers: they all - # publish into it, and checking `[ -d ]` inside a worker races its siblings - # (every worker would still pay the mkdir fork). - if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then - local _suite_base="${script##*/}" - mkdir -p "${TEMP_DIR_PARALLEL_TEST_SUITE}/${_suite_base%.sh}" 2>/dev/null || true - fi - - for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do - if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then - break - fi - - # No data provider found: run once without forking to capture provider output. - bashunit::helper::provider_for_function "$fn_name" - if [ -z "$_BASHUNIT_PROVIDER_FN_OUT" ]; then - if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then - bashunit::runner::wait_for_job_slot - _test_ordinal=$((_test_ordinal + 1)) - _BASHUNIT_RUNNER_RESULT_ORDINAL=$_test_ordinal - bashunit::runner::run_test "$script" "$fn_name" & - else - bashunit::runner::run_test "$script" "$fn_name" - fi - unset -v fn_name - continue - fi - - provider_data=() - provider_data_count=0 - local line - while IFS=" " read -r line; do - [ -z "$line" ] && continue - provider_data[provider_data_count]="$line" - provider_data_count=$((provider_data_count + 1)) - done <<<"$(bashunit::helper::execute_function_if_exists "$_BASHUNIT_PROVIDER_FN_OUT")" - - # Execute the test function for each line of data - local data - for data in "${provider_data[@]+"${provider_data[@]}"}"; do - parsed_data=() - parsed_data_count=0 - local line - while IFS= read -r line; do - [ -z "$line" ] && continue - parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")" - parsed_data_count=$((parsed_data_count + 1)) - done <<<"$(bashunit::runner::parse_data_provider_args "$data")" - if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then - bashunit::runner::wait_for_job_slot - _test_ordinal=$((_test_ordinal + 1)) - _BASHUNIT_RUNNER_RESULT_ORDINAL=$_test_ordinal - bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} & - else - bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} - fi - done - unset -v fn_name - done - - # Wait for all parallel tests within this file to complete - if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then - wait - fi -} - -function bashunit::runner::call_bench_functions() { - local script="$1" - local filter="$2" - local IFS=$' \t\n' - local prefix="bench" - - # Use cached function names for better performance - local filtered_functions - filtered_functions=$(bashunit::helper::get_functions_to_run \ - "$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") - local -a functions_to_run=() - local functions_to_run_count=0 - local _fn - while IFS= read -r _fn; do - [ -z "$_fn" ] && continue - functions_to_run[functions_to_run_count]="$_fn" - functions_to_run_count=$((functions_to_run_count + 1)) - done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions") - - if [ "$functions_to_run_count" -le 0 ]; then - return - fi - - if bashunit::env::is_bench_mode_enabled; then - bashunit::runner::render_running_file_header "$script" - fi - - local fn_name - for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do - # Capture separately so a malformed annotation aborts the run: the exit - # status of a $(...) inside `read <<<` is otherwise discarded (#884). - local parsed_annotations - parsed_annotations=$(bashunit::benchmark::parse_annotations "$fn_name" "$script") || exit 1 - read -r revs its max_ms <<<"$parsed_annotations" - bashunit::benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" - unset -v fn_name - done - - if ! bashunit::env::is_simple_output_enabled; then - echo "" - fi -} - -function bashunit::runner::render_running_file_header() { - local script="$1" - local force="${2:-false}" - - bashunit::internal_log "Running file" "$script" - - if [ "$force" != true ] && bashunit::parallel::is_enabled; then - return - fi - - # Suppress file headers in failures-only mode - if bashunit::env::is_failures_only_enabled; then - return - fi - - # Suppress file headers in no-progress mode - if bashunit::env::is_no_progress_enabled; then - return - fi - - if bashunit::env::is_tap_output_enabled; then - printf "# %s\n" "$script" - elif ! bashunit::env::is_simple_output_enabled; then - if bashunit::env::is_verbose_enabled; then - printf "\n${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" "Running $script" - else - printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" "Running $script" - fi - elif bashunit::env::is_verbose_enabled; then - printf "\n\n${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}" "Running $script" - fi -} - -# Result slots for the timeout-aware execution path (see run_with_timeout). -_BASHUNIT_RUNNER_EXEC_OUT="" -_BASHUNIT_RUNNER_TIMED_OUT="false" - -## -# Runs a single test inside the capture subshell: sets up the EXIT trap that -# encodes assertion counts/exit code, runs set_up, applies the shell mode and -# finally invokes the test function. Meant to be called from a subshell (either -# the `$(...)` capture or a backgrounded job), so its `set`/`trap`/`exit` calls -# stay isolated. Emits the test stdout (with stderr merged) followed by the -# encoded context from cleanup_on_exit. -# Arguments: $1 test file, $2 function name, $@ test args -## -function bashunit::runner::execute_test_body() { - local test_file=$1 - shift - local fn_name=$1 - shift - - # Save subshell stdout to FD 5 so the EXIT trap can restore it. - # When set -e kills the subshell during a redirected block in - # execute_test_hook, the redirect leaks into the EXIT trap, - # causing export_subshell_context output to be lost. - exec 5>&1 - # shellcheck disable=SC2064 - trap "exit_code=\$?; bashunit::runner::cleanup_on_exit \"$test_file\" \"\$exit_code\"" EXIT - bashunit::state::initialize_assertions_count - - if bashunit::env::is_login_shell_enabled; then - bashunit::runner::source_login_shell_profiles - fi - - # Enable coverage tracking early to include set_up/tear_down hooks - if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then - bashunit::coverage::enable_trap - fi - - # Run set_up and capture exit code without || to preserve errexit behavior - # shellcheck disable=SC2030 - _BASHUNIT_SETUP_COMPLETED=false - local setup_exit_code=0 - bashunit::runner::run_set_up "$test_file" - setup_exit_code=$? - _BASHUNIT_SETUP_COMPLETED=true - if [ $setup_exit_code -ne 0 ]; then - exit $setup_exit_code - fi - - # Apply shell mode setting for test execution - if bashunit::env::is_strict_mode_enabled; then - set -eu - # Bash 3.0 ships a broken pipefail; only enable it where it is reliable. - if bashunit::runner::_supports_reliable_pipefail; then - set -o pipefail - else - set +o pipefail - fi - else - set +euo pipefail - fi - - # 2>&1: Redirects the std-error (FD 2) to the std-output (FD 1). - # points to the original std-output. - "$fn_name" "$@" 2>&1 -} - -## -# Prints an encoded subshell result for a test that timed out: empty assertion -# counters and exit code 124 (the conventional "timed out" code, already mapped -# by classify_kill_signal). The empty TEST_HOOK_MESSAGE/TITLE/OUTPUT fields would -# base64-encode to an empty string anyway, so the line is emitted directly rather -# than mutating the shared _BASHUNIT_* globals (it mirrors the layout produced by -# bashunit::state::export_subshell_context). Bash 3.0+ compatible. -## -function bashunit::runner::build_timeout_result() { - printf '%s' "##ASSERTIONS_FAILED=0##ASSERTIONS_PASSED=0##ASSERTIONS_SKIPPED=0\ -##ASSERTIONS_INCOMPLETE=0##ASSERTIONS_SNAPSHOT=0##TEST_EXIT_CODE=124\ -##TEST_HOOK_FAILURE=##TEST_HOOK_MESSAGE=##TEST_TITLE=##TEST_OUTPUT=##" -} - -## -# Runs the test body with a watchdog that kills it after BASHUNIT_TEST_TIMEOUT -# seconds. The body runs as a backgrounded job in its own process group (set -m) -# so the watchdog can SIGTERM/SIGKILL the whole tree — a hanging test usually -# blocks in a child process, which signalling the subshell alone cannot reach. -# Writes the captured result to _BASHUNIT_RUNNER_EXEC_OUT and "true"/"false" to -# _BASHUNIT_RUNNER_TIMED_OUT. Bash 3.0+ compatible (validated on Bash 3.2). -# Arguments: $1 test file, $2 function name, $@ test args -## -function bashunit::runner::run_with_timeout() { - local test_file=$1 - shift - local fn_name=$1 - shift - local secs - secs=$(bashunit::env::test_timeout_secs) - - # NOTE: these must NOT use bashunit::temp_file — that prefixes the current - # test id, and cleanup_on_exit (run inside the test subshell) would unlink - # them via cleanup_testcase_temp_files before we read them back here. - local tmp_dir="${BASHUNIT_TEMP_DIR:-${TMPDIR:-/tmp}}" - local out_file marker_file - out_file="$("$MKTEMP" "$tmp_dir/bashunit_timeout_out.XXXXXXX")" - marker_file="$("$MKTEMP" "$tmp_dir/bashunit_timeout_marker.XXXXXXX")" - rm -f "$marker_file" - - # Both jobs run in their own process group (set -m) so each can be killed as a - # whole tree. The body MUST run in an explicit ( ) subshell: a backgrounded { } - # group does not run its EXIT trap on normal completion, which would drop the - # encoded assertion context. The watchdog's fds are detached from the caller so - # a lingering `sleep` can never hold a captured stdout pipe open. - set -m - (bashunit::runner::execute_test_body "$test_file" "$fn_name" "$@") >"$out_file" 2>&1 & - local test_pid=$! - ( - sleep "$secs" - # Only a still-running test can have timed out. Without this guard a watchdog - # that outlived a missed teardown (see below) would mark an already-finished - # fast test as timed out. - kill -0 "$test_pid" 2>/dev/null || exit 0 - : >"$marker_file" - kill -TERM -"$test_pid" 2>/dev/null - sleep 0.3 - kill -KILL -"$test_pid" 2>/dev/null - ) /dev/null 2>&1 & - local watchdog_pid=$! - set +m - - wait "$test_pid" 2>/dev/null - # Stop the watchdog by its pid AND its group. `set -m` does not reliably make a - # backgrounded subshell a group leader in a non-interactive shell, so the - # group-only kill intermittently misses, letting the watchdog sleep its full - # timeout and fire against a test that already passed. The direct-pid signal is - # always deliverable; the group signal also reaps the `sleep` child. - kill -TERM "$watchdog_pid" 2>/dev/null - kill -TERM -"$watchdog_pid" 2>/dev/null - wait "$watchdog_pid" 2>/dev/null - - if [ -f "$marker_file" ]; then - _BASHUNIT_RUNNER_TIMED_OUT="true" - _BASHUNIT_RUNNER_EXEC_OUT="$(bashunit::runner::build_timeout_result)" - else - _BASHUNIT_RUNNER_TIMED_OUT="false" - _BASHUNIT_RUNNER_EXEC_OUT="$(cat "$out_file" 2>/dev/null)" - fi - - rm -f "$out_file" "$marker_file" -} - -# Per-test duration is consumed by --profile, --verbose, report files, and the -# execution-time display. When none are active we can skip the clock reads, -# which matters when the clock forks an interpreter (#765). -function bashunit::runner::needs_test_duration() { - bashunit::env::is_profile_enabled && return 0 - bashunit::env::is_verbose_enabled && return 0 - bashunit::reports::is_enabled && return 0 - bashunit::env::is_show_execution_time_enabled && return 0 - return 1 -} - -function bashunit::runner::run_test() { - local start_time=0 - - local test_file="$1" - shift - local fn_name="$1" - shift - - bashunit::internal_log "Running test" "$fn_name" "$*" - bashunit::runner::export_test_identity "$test_file" "$fn_name" - - bashunit::state::reset_test_title - bashunit::runner::apply_interpolated_title "$fn_name" "$@" - local interpolated_fn_name=$_BASHUNIT_RUNNER_INTERP_OUT - local current_assertions_failed="$_BASHUNIT_ASSERTIONS_FAILED" - local current_assertions_snapshot="$_BASHUNIT_ASSERTIONS_SNAPSHOT" - local current_assertions_incomplete="$_BASHUNIT_ASSERTIONS_INCOMPLETE" - local current_assertions_skipped="$_BASHUNIT_ASSERTIONS_SKIPPED" - - # (FD = File Descriptor) - # Duplicate the current std-output (FD 1) and assigns it to FD 3. - # This means that FD 3 now points to wherever the std-output was pointing. - exec 3>&1 - - local test_execution_result - local timed_out="false" - bashunit::env::resolve_retry_count - local retry_max=$_BASHUNIT_RETRY_VALIDATED - local retries_used=0 - local measure_duration=false - bashunit::runner::needs_test_duration && measure_duration=true - # Retry wraps ONLY execution: a failed attempt is judged from its encoded - # result without committing, so the parse/report/counter path below still runs - # exactly once (on the final attempt) and nothing is double-counted. Each fork - # in --parallel retries itself before writing its single .result file. - while :; do - if [ "$measure_duration" = true ]; then - bashunit::clock::now_to_slot - start_time=$_BASHUNIT_CLOCK_NOW_OUT - fi - if bashunit::env::is_test_timeout_enabled; then - bashunit::runner::run_with_timeout "$test_file" "$fn_name" "$@" - test_execution_result="$_BASHUNIT_RUNNER_EXEC_OUT" - timed_out="$_BASHUNIT_RUNNER_TIMED_OUT" - else - test_execution_result=$(bashunit::runner::execute_test_body "$test_file" "$fn_name" "$@") - fi - - local attempt_runtime_output="${test_execution_result%%##ASSERTIONS_*}" - bashunit::runner::detect_runtime_error "$attempt_runtime_output" - local attempt_runtime_error=$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT - bashunit::runner::extract_result_counts "$test_execution_result" - # Mirror the commit-phase failure test exactly (runtime error, non-zero exit, - # or a failed assertion); snapshot/incomplete/skipped/risky are not failures. - if [ -z "$attempt_runtime_error" ] && - [ "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" -eq 0 ] && - [ "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" -eq 0 ]; then - break - fi - [ "$retries_used" -ge "$retry_max" ] && break - retries_used=$((retries_used + 1)) - done - - # Closes FD 3, which was used temporarily to hold the original stdout. - exec 3>&- - - local duration=0 - if [ "$measure_duration" = true ]; then - bashunit::clock::now_to_slot - local end_time=$_BASHUNIT_CLOCK_NOW_OUT - duration=$(((end_time - start_time) / 1000000)) - fi - - if bashunit::env::is_profile_enabled; then - bashunit::runner::record_profile "$duration" "$interpolated_fn_name" "$test_file" - fi - - if bashunit::env::is_verbose_enabled; then - bashunit::runner::print_verbose_test_summary \ - "$test_file" "$fn_name" "$duration" "$test_execution_result" - fi - - bashunit::runner::decode_subshell_output "$test_execution_result" - local subshell_output=$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT - - if [ -n "$subshell_output" ]; then - bashunit::runner::extract_subshell_type "$subshell_output" - local type=$_BASHUNIT_RUNNER_TYPE_OUT - bashunit::runner::format_subshell_output "$subshell_output" - subshell_output=$_BASHUNIT_RUNNER_OUTPUT_OUT - if ! bashunit::env::is_failures_only_enabled; then - bashunit::console_results::print_line "$type" "$subshell_output" - fi - fi - - # Reuse the final attempt's values (the loop always runs at least once and - # its locals persist in this function scope), instead of recomputing and - # forking detect_runtime_error a second time (#764). - local runtime_output=$attempt_runtime_output - local runtime_error=$attempt_runtime_error - - # parse_result accumulates _BASHUNIT_TEST_EXIT_CODE; reset it so each test's - # exit code is read in isolation (a non-zero/timed-out test must not poison - # the next one). - _BASHUNIT_TEST_EXIT_CODE=0 - bashunit::runner::parse_result "$fn_name" "$test_execution_result" "$@" - - local test_exit_code="$_BASHUNIT_TEST_EXIT_CODE" - - bashunit::runner::compute_total_assertions "$test_execution_result" - local total_assertions=$_BASHUNIT_RUNNER_TOTAL_OUT - - bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_TITLE" - local encoded_test_title=$_BASHUNIT_RUNNER_FIELD_OUT - bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_HOOK_FAILURE" - local hook_failure=$_BASHUNIT_RUNNER_FIELD_OUT - bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_HOOK_MESSAGE" - local encoded_hook_message=$_BASHUNIT_RUNNER_FIELD_OUT - - local test_title="" - [ -n "$encoded_test_title" ] && test_title="$(bashunit::helper::decode_base64 "$encoded_test_title")" - local hook_message="" - [ -n "$encoded_hook_message" ] && hook_message="$(bashunit::helper::decode_base64 "$encoded_hook_message")" - - bashunit::set_test_title "$test_title" - bashunit::helper::normalize_test_function_name_to_slot "$fn_name" "$interpolated_fn_name" - local label=$_BASHUNIT_HELPER_NORMALIZED_OUT - bashunit::state::reset_test_title - bashunit::state::reset_current_test_interpolated_function_name - - local failure_label="$label" - local failure_function="$fn_name" - if [ -n "$hook_failure" ]; then - bashunit::helper::normalize_test_function_name_to_slot "$hook_failure" - failure_label=$_BASHUNIT_HELPER_NORMALIZED_OUT - failure_function="$hook_failure" - fi - - if [ -n "$runtime_error" ] || [ "$test_exit_code" -ne 0 ]; then - bashunit::state::add_tests_failed - bashunit::rerun::record "$test_file" "$fn_name" - local error_message="$runtime_error" - if [ -n "$hook_failure" ] && [ -n "$hook_message" ]; then - error_message="$hook_message" - elif [ -z "$error_message" ] && [ -n "$hook_message" ]; then - error_message="$hook_message" - fi - - # When the test was killed by a signal (or timed out), replace an empty or - # generic "Killed" message with a specific cause. - if [ -z "$hook_failure" ]; then - local kill_message - kill_message=$(bashunit::runner::classify_kill_signal "$test_exit_code") - if [ -n "$kill_message" ]; then - case "$error_message" in - '' | *[Kk]illed* | *[Tt]erminated*) error_message="$kill_message" ;; - esac - fi - fi - - # A test that exceeded BASHUNIT_TEST_TIMEOUT gets a clear, specific message. - if [ "$timed_out" = "true" ]; then - error_message="Test timed out after $(bashunit::env::test_timeout_secs)s" - fi - - bashunit::console_results::print_error_test "$failure_function" "$error_message" "$runtime_output" - bashunit::reports::add_test_failed "$test_file" "$failure_label" "$duration" "$total_assertions" "$error_message" - bashunit::runner::write_failure_result_output "$test_file" "$failure_function" "$error_message" "$runtime_output" - bashunit::internal_log "Test error" "$failure_label" "$error_message" - - bashunit::runner::halt_if_stop_on_failure - return - fi - - if [ "$current_assertions_failed" != "$_BASHUNIT_ASSERTIONS_FAILED" ]; then - bashunit::state::add_tests_failed - bashunit::rerun::record "$test_file" "$fn_name" - bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$subshell_output" - local assertion_runtime_output - assertion_runtime_output="$( - bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$subshell_output" - )" - bashunit::runner::write_failure_result_output \ - "$test_file" "$fn_name" "$subshell_output" "$assertion_runtime_output" - - bashunit::internal_log "Test failed" "$label" - - bashunit::runner::halt_if_stop_on_failure - return - fi - - if [ "$current_assertions_snapshot" != "$_BASHUNIT_ASSERTIONS_SNAPSHOT" ]; then - bashunit::state::add_tests_snapshot - # In failures-only mode, suppress snapshot test output - if ! bashunit::env::is_failures_only_enabled; then - bashunit::console_results::print_snapshot_test "$label" - fi - bashunit::reports::add_test_snapshot "$test_file" "$label" "$duration" "$total_assertions" - bashunit::internal_log "Test snapshot" "$label" - return - fi - - if [ "$current_assertions_incomplete" != "$_BASHUNIT_ASSERTIONS_INCOMPLETE" ]; then - bashunit::state::add_tests_incomplete - bashunit::reports::add_test_incomplete "$test_file" "$label" "$duration" "$total_assertions" - bashunit::runner::write_incomplete_result_output "$test_file" "$fn_name" "$subshell_output" - bashunit::internal_log "Test incomplete" "$label" - return - fi - - if [ "$current_assertions_skipped" != "$_BASHUNIT_ASSERTIONS_SKIPPED" ]; then - bashunit::state::add_tests_skipped - bashunit::reports::add_test_skipped "$test_file" "$label" "$duration" "$total_assertions" - bashunit::runner::write_skipped_result_output "$test_file" "$fn_name" "$subshell_output" - bashunit::internal_log "Test skipped" "$label" - return - fi - - # Check for risky test (zero assertions) - if [ "$total_assertions" -eq 0 ]; then - if bashunit::env::is_fail_on_risky_enabled; then - local risky_msg="Test has no assertions (risky)" - bashunit::state::add_tests_failed - bashunit::rerun::record "$test_file" "$fn_name" - bashunit::console_results::print_error_test "$fn_name" "$risky_msg" - bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$risky_msg" - bashunit::runner::write_failure_result_output "$test_file" "$fn_name" "$risky_msg" - bashunit::internal_log "Test failed (risky)" "$label" - bashunit::runner::halt_if_stop_on_failure - return - fi - bashunit::state::add_tests_risky - if ! bashunit::env::is_failures_only_enabled; then - bashunit::console_results::print_risky_test "${label}" "$duration" - fi - bashunit::reports::add_test_risky "$test_file" "$label" "$duration" "$total_assertions" - bashunit::runner::write_risky_result_output "$test_file" "$fn_name" - bashunit::internal_log "Test risky" "$label" - return - fi - - # A test that only passed after retrying is annotated so flakiness stays visible. - _BASHUNIT_RETRY_NOTE="" - if [ "$retries_used" -gt 0 ]; then - _BASHUNIT_RETRY_NOTE=" (retry $retries_used/$retry_max)" - fi - # In failures-only mode, suppress successful test output - if ! bashunit::env::is_failures_only_enabled; then - if [ "$fn_name" = "$interpolated_fn_name" ]; then - bashunit::console_results::print_successful_test "${label}" "$duration" "$@" - else - bashunit::console_results::print_successful_test "${label}" "$duration" - fi - fi - _BASHUNIT_RETRY_NOTE="" - bashunit::state::add_tests_passed - bashunit::reports::add_test_passed "$test_file" "$label" "$duration" "$total_assertions" - bashunit::internal_log "Test passed" "$label" -} - -function bashunit::runner::cleanup_on_exit() { - local test_file="$1" - local exit_code="$2" - - # Disable coverage trap before cleanup to avoid interference - if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then - bashunit::coverage::disable_trap - fi - - set +e - - # Settle a bashunit::assert_once marker the test body left open, before - # tear_down runs its own assertions and before the counters are exported. - bashunit::assert::once_flush - - # Detect unexpected subshell exit during set_up (Issue #611). - # When 'source' of a non-existent file fails under set -eE, the ERR trap - # does not fire. On macOS Bash 3.2, $? is 0 in the EXIT trap; on Linux - # Bash 5.x, $? is 1. In both cases the hook failure is not recorded. - # Additionally, the stdout redirect from execute_test_hook leaks into the - # EXIT trap. Restore stdout from saved FD 5 so export_subshell_context - # output reaches test_execution_result. - # shellcheck disable=SC2031 - if [ "${_BASHUNIT_SETUP_COMPLETED:-true}" != "true" ]; then - exec 1>&5 - if [ "$exit_code" -eq 0 ]; then - exit_code=1 - fi - if [ -z "${_BASHUNIT_TEST_HOOK_FAILURE:-}" ]; then - bashunit::state::set_test_hook_failure "set_up" - bashunit::state::set_test_hook_message "Hook 'set_up' failed unexpectedly (e.g., source of non-existent file)" - fi - fi - - # Don't use || here - it disables ERR trap in the entire call chain - bashunit::runner::run_tear_down "$test_file" - local teardown_status=$? - bashunit::runner::clear_mocks - bashunit::cleanup_testcase_temp_files - - if [ $teardown_status -ne 0 ]; then - bashunit::state::set_test_exit_code "$teardown_status" - else - bashunit::state::set_test_exit_code "$exit_code" - fi - - bashunit::state::export_subshell_context -} - -# Writes the decoded subshell output into _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT. -# The empty case (a passing test with no captured output) short-circuits with -# no subshell at all; only the non-empty path pays the base64 fork (#762/#764). -# Arguments: $1 test_execution_result -function bashunit::runner::decode_subshell_output() { - local test_execution_result="$1" - - local test_output_base64="${test_execution_result##*##TEST_OUTPUT=}" - test_output_base64="${test_output_base64%%##*}" - if [ -z "$test_output_base64" ] || [ "$test_output_base64" = "$_BASHUNIT_BASE64_EMPTY_SENTINEL" ]; then - _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="" - return - fi - _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="$(bashunit::helper::decode_base64 "$test_output_base64")" -} - -function bashunit::runner::is_simple_progress_output() { - local output="$1" - - [ -n "$output" ] || return 1 - - local color - for color in \ - "$_BASHUNIT_COLOR_DEFAULT" \ - "$_BASHUNIT_COLOR_PASSED" \ - "$_BASHUNIT_COLOR_FAILED" \ - "$_BASHUNIT_COLOR_SKIPPED" \ - "$_BASHUNIT_COLOR_INCOMPLETE" \ - "$_BASHUNIT_COLOR_SNAPSHOT" \ - "$_BASHUNIT_COLOR_RISKY"; do - [ -n "$color" ] && output="${output//"$color"/}" - done - - local i - local char - for ((i = 0; i < ${#output}; i++)); do - char="${output:$i:1}" - case "$char" in - "." | "F" | "S" | "I" | "N" | "R" | "E" | "?") ;; - *) return 1 ;; - esac - done - - return 0 -} - -function bashunit::runner::line_exists_in_output() { - local needle="$1" - local haystack="$2" - local line - - while IFS= read -r line || [ -n "$line" ]; do - [ "$line" = "$needle" ] && return 0 - done <<<"$haystack" - - return 1 -} - -function bashunit::runner::extract_assertion_runtime_output() { - local runtime_output="$1" - local rendered_assertion_output="$2" - local filtered_output="" - local line - - while IFS= read -r line || [ -n "$line" ]; do - if bashunit::runner::line_exists_in_output "$line" "$rendered_assertion_output"; then - continue - fi - if bashunit::runner::is_simple_progress_output "$line"; then - continue - fi - - [ -n "$filtered_output" ] && filtered_output="$filtered_output"$'\n' - filtered_output="$filtered_output$line" - done <<<"$runtime_output" - - runtime_output="$filtered_output" - - while [ -n "$runtime_output" ]; do - case "$runtime_output" in - *$'\n') runtime_output="${runtime_output%$'\n'}" ;; - *) break ;; - esac - done - - echo "$runtime_output" -} - -function bashunit::runner::parse_result() { - local fn_name=$1 - shift - local execution_result=$1 - shift - local IFS=$' \t\n' - local -a args - args=("$@") - - if bashunit::parallel::is_enabled; then - bashunit::runner::parse_result_parallel "$fn_name" "$execution_result" ${args+"${args[@]}"} - else - bashunit::runner::parse_result_sync "$fn_name" "$execution_result" - fi -} - -function bashunit::runner::parse_result_parallel() { - local fn_name=$1 - shift - local execution_result=$1 - # This runs once per test in every parallel worker, so avoid per-test forks: - # derive the suite dir name with parameter expansion (no basename), only - # mkdir when the dir is missing (first test of the file wins the race, - # `-p` makes the losers no-ops), and name the result file by the per-suite - # ordinal the dispatcher assigned — unique without forking mktemp or mv. - local test_suite_base="${test_file##*/}" - local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/${test_suite_base%.sh}" - [ -d "$test_suite_dir" ] || mkdir -p "$test_suite_dir" - - local unique_test_result_file="${test_suite_dir}/${_BASHUNIT_RUNNER_RESULT_ORDINAL}.result" - - bashunit::internal_log "[PARA]" "fn_name:$fn_name" "execution_result:$execution_result" - - bashunit::runner::parse_result_sync "$fn_name" "$execution_result" - - echo "$execution_result" >"$unique_test_result_file" -} - -# shellcheck disable=SC2295 -## -# Parses the encoded per-test result's last line into the counts out-slots -# (_BASHUNIT_RUNNER_COUNTS_*_OUT). Pure read: never mutates the cumulative -# _BASHUNIT_ASSERTIONS_* / _BASHUNIT_TEST_EXIT_CODE state, so the retry loop can -# judge an attempt's outcome without committing it. -## -function bashunit::runner::extract_result_counts() { - local execution_result=$1 - - local result_line - result_line="${execution_result##*$'\n'}" - - local assertions_failed=0 - local assertions_passed=0 - local assertions_skipped=0 - local assertions_incomplete=0 - local assertions_snapshot=0 - local test_exit_code=0 - - # Extract values using parameter expansion instead of spawning grep/sed subprocesses - case "$result_line" in - *"ASSERTIONS_FAILED="*"##ASSERTIONS_PASSED="*) - local _tail - _tail="${result_line##*ASSERTIONS_FAILED=}" - assertions_failed="${_tail%%##*}" - _tail="${result_line##*ASSERTIONS_PASSED=}" - assertions_passed="${_tail%%##*}" - _tail="${result_line##*ASSERTIONS_SKIPPED=}" - assertions_skipped="${_tail%%##*}" - _tail="${result_line##*ASSERTIONS_INCOMPLETE=}" - assertions_incomplete="${_tail%%##*}" - _tail="${result_line##*ASSERTIONS_SNAPSHOT=}" - assertions_snapshot="${_tail%%##*}" - _tail="${result_line##*TEST_EXIT_CODE=}" - test_exit_code="${_tail%%##*}" - # Strip any trailing non-digit suffix (end of line) from the final field - test_exit_code="${test_exit_code%%[!0-9]*}" - : "${assertions_failed:=0}" - : "${assertions_passed:=0}" - : "${assertions_skipped:=0}" - : "${assertions_incomplete:=0}" - : "${assertions_snapshot:=0}" - : "${test_exit_code:=0}" - ;; - esac - - _BASHUNIT_RUNNER_COUNTS_FAILED_OUT=$assertions_failed - _BASHUNIT_RUNNER_COUNTS_PASSED_OUT=$assertions_passed - _BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT=$assertions_skipped - _BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT=$assertions_incomplete - _BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT=$assertions_snapshot - _BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT=$test_exit_code -} - -function bashunit::runner::parse_result_sync() { - local fn_name=$1 - local execution_result=$2 - - bashunit::runner::extract_result_counts "$execution_result" - - bashunit::internal_log "[SYNC]" "fn_name:$fn_name" "execution_result:$execution_result" - - _BASHUNIT_ASSERTIONS_PASSED=$((_BASHUNIT_ASSERTIONS_PASSED + _BASHUNIT_RUNNER_COUNTS_PASSED_OUT)) - _BASHUNIT_ASSERTIONS_FAILED=$((_BASHUNIT_ASSERTIONS_FAILED + _BASHUNIT_RUNNER_COUNTS_FAILED_OUT)) - _BASHUNIT_ASSERTIONS_SKIPPED=$((_BASHUNIT_ASSERTIONS_SKIPPED + _BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT)) - _BASHUNIT_ASSERTIONS_INCOMPLETE=$((_BASHUNIT_ASSERTIONS_INCOMPLETE + _BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT)) - _BASHUNIT_ASSERTIONS_SNAPSHOT=$((_BASHUNIT_ASSERTIONS_SNAPSHOT + _BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT)) - _BASHUNIT_TEST_EXIT_CODE=$((_BASHUNIT_TEST_EXIT_CODE + _BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT)) - - bashunit::internal_log "result_summary" \ - "failed:$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" \ - "passed:$_BASHUNIT_RUNNER_COUNTS_PASSED_OUT" \ - "skipped:$_BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT" \ - "incomplete:$_BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT" \ - "snapshot:$_BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT" \ - "exit_code:$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" -} - -function bashunit::runner::write_failure_result_output() { - local test_file=$1 - local fn_name=$2 - local error_msg=$3 - local raw_output="${4:-}" - - local line_number - line_number=$(bashunit::helper::get_function_line_number "$fn_name") - - local test_nr="*" - if ! bashunit::parallel::is_enabled; then - test_nr=$(bashunit::state::get_tests_failed) - fi - - local output_section="" - if [ -n "$raw_output" ] && bashunit::env::is_show_output_on_failure_enabled; then - output_section="\n Output:\n$raw_output" - fi - - local source_context="" - if [ -n "$line_number" ] && [ -f "$test_file" ]; then - source_context=$(bashunit::runner::get_failure_source_context \ - "$test_file" "$line_number") - fi - - echo -e "$test_nr) $test_file:$line_number\n$error_msg$output_section$source_context" \ - >>"$FAILURES_OUTPUT_PATH" -} - -function bashunit::runner::get_failure_source_context() { - local file=$1 - local fn_line=$2 - - # Read the file once (a bash builtin loop) instead of forking `sed` to fetch - # each line and `grep` to test each line for the closing brace. The fork count - # no longer grows with the function length. - local line_text line_num=0 assert_lines="" stripped trimmed - while IFS= read -r line_text || [ -n "$line_text" ]; do - line_num=$((line_num + 1)) - # Skip everything up to and including the function definition line. - if [ "$line_num" -le "$fn_line" ]; then - continue - fi - # Stop at the closing brace of the function (a line that is only `}`). - stripped="${line_text#"${line_text%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - if [ "$stripped" = "}" ]; then - break - fi - # Collect lines containing assert calls - case "$line_text" in - *assert_* | *assert\ *) - trimmed="${line_text#"${line_text%%[![:space:]]*}"}" - assert_lines="${assert_lines}\n ${_BASHUNIT_COLOR_FAINT}${line_num}:${_BASHUNIT_COLOR_DEFAULT} ${trimmed}" - ;; - esac - done <"$file" - - if [ -n "$assert_lines" ]; then - echo -e "\n ${_BASHUNIT_COLOR_FAINT}Source:${_BASHUNIT_COLOR_DEFAULT}${assert_lines}" - fi -} - -function bashunit::runner::write_skipped_result_output() { - local test_file=$1 - local fn_name=$2 - local output_msg=$3 - - local line_number - line_number=$(bashunit::helper::get_function_line_number "$fn_name") - - local test_nr="*" - if ! bashunit::parallel::is_enabled; then - test_nr=$(bashunit::state::get_tests_skipped) - fi - - echo -e "$test_nr) $test_file:$line_number\n$output_msg" >>"$SKIPPED_OUTPUT_PATH" -} - -function bashunit::runner::write_incomplete_result_output() { - local test_file=$1 - local fn_name=$2 - local output_msg=$3 - - local line_number - line_number=$(bashunit::helper::get_function_line_number "$fn_name") - - local test_nr="*" - if ! bashunit::parallel::is_enabled; then - test_nr=$(bashunit::state::get_tests_incomplete) - fi - - echo -e "$test_nr) $test_file:$line_number\n$output_msg" >>"$INCOMPLETE_OUTPUT_PATH" -} - -function bashunit::runner::write_risky_result_output() { - local test_file=$1 - local fn_name=$2 - - local line_number - line_number=$(bashunit::helper::get_function_line_number "$fn_name") - - local test_nr="*" - if ! bashunit::parallel::is_enabled; then - test_nr=$(bashunit::state::get_tests_risky) - fi - - echo -e "$test_nr) $test_file:$line_number\nTest has no assertions (risky)" >>"$RISKY_OUTPUT_PATH" -} - -function bashunit::runner::record_file_hook_failure() { - local hook_name="$1" - local test_file="$2" - local hook_output="$3" - local status="$4" - local render_header="${5:-false}" - - if [ "$render_header" = true ]; then - bashunit::runner::render_running_file_header "$test_file" true - fi - - if [ -z "$hook_output" ]; then - hook_output="Hook '$hook_name' failed with exit code $status" - fi - - bashunit::state::add_tests_failed - bashunit::console_results::print_error_test "$hook_name" "$hook_output" - local _normalized_hook - _normalized_hook="$(bashunit::helper::normalize_test_function_name "$hook_name")" - bashunit::reports::add_test_failed "$test_file" "$_normalized_hook" 0 0 "$hook_output" - bashunit::runner::write_failure_result_output "$test_file" "$hook_name" "$hook_output" - - return "$status" -} - -function bashunit::runner::execute_file_hook() { - local hook_name="$1" - local test_file="$2" - local render_header="${3:-false}" - - declare -F "$hook_name" >/dev/null 2>&1 || return 0 - - local hook_output="" - local status=0 - local hook_output_file - hook_output_file=$(bashunit::temp_file "${hook_name}_output") - - # Enable errtrace to catch any failing command in the hook. - # Using -E (errtrace) without -e (errexit) prevents the main process from - # exiting on source failures (Bash 3.2 doesn't trigger ERR trap with -eE). - # The ERR trap saves the exit status to a global variable, cleans up shell - # options, and returns from the hook function to prevent subsequent commands - # from executing. - # Variables set before the failure are preserved since we don't use a subshell. - _BASHUNIT_HOOK_ERR_STATUS=0 - set -E - if bashunit::env::is_strict_mode_enabled; then - set -uo pipefail - fi - # The trap returns from the function where the failure occurred (early-exit - # semantics for intermediate failing commands) — but only when that frame is - # NOT this executor: on Bash >= 4 the trap also fires HERE when the hook call - # itself returns non-zero, and an unconditional return skipped - # record_file_hook_failure entirely (silent failures, off-by-one counts, #836). - # shellcheck disable=SC2154 - trap '_BASHUNIT_HOOK_ERR_STATUS=$? - if [ "${FUNCNAME[0]:-}" != "bashunit::runner::execute_file_hook" ]; then - set +Eu +o pipefail - trap - ERR - return $_BASHUNIT_HOOK_ERR_STATUS - fi' ERR - - { - "$hook_name" - } >"$hook_output_file" 2>&1 - # Real exit status of the hook, read from $? (this function runs without -e, - # so a failing compound does not exit). The ERR-trap global alone is not - # enough: a hook ending in a failing `cmd && var=x` guard returns non-zero - # without ever firing the trap (&& lists are ERR-exempt), which silently - # swallowed the failure (#836). - status=$? - if [ "$status" -eq 0 ]; then - status=$_BASHUNIT_HOOK_ERR_STATUS - fi - - trap - ERR - set +Eu +o pipefail - - if [ -f "$hook_output_file" ]; then - hook_output="" - local line - while IFS= read -r line; do - [ -z "$hook_output" ] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" - done <"$hook_output_file" - rm -f "$hook_output_file" - fi - - if [ $status -ne 0 ]; then - bashunit::runner::record_file_hook_failure "$hook_name" "$test_file" "$hook_output" "$status" "$render_header" - return $status - fi - - if [ -n "$hook_output" ] && bashunit::env::is_verbose_enabled; then - printf "%s\n" "$hook_output" - fi - - return 0 -} - -function bashunit::runner::run_set_up() { - local _test_file="${1-}" - bashunit::internal_log "run_set_up" - bashunit::runner::execute_test_hook 'set_up' -} - -function bashunit::runner::run_set_up_before_script() { - local test_file="$1" - bashunit::internal_log "run_set_up_before_script" - - # Check if hook exists first - if ! declare -F "set_up_before_script" >/dev/null 2>&1; then - return 0 - fi - - local start_time - start_time=$(bashunit::clock::now) - - # Enable coverage trap to attribute lines executed during set_up_before_script - if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then - bashunit::coverage::enable_trap - fi - - # Execute the hook (render_header=false since header is already rendered) - bashunit::runner::execute_file_hook 'set_up_before_script' "$test_file" false - local status=$? - - # Disable coverage trap after hook execution - if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then - bashunit::coverage::disable_trap - fi - - local end_time - end_time=$(bashunit::clock::now) - local duration_ns=$((end_time - start_time)) - local duration_ms=$((duration_ns / 1000000)) - - # Print completion message only if hook succeeded - if [ $status -eq 0 ]; then - bashunit::console_results::print_hook_completed "set_up_before_script" "$duration_ms" - fi - - return $status -} - -function bashunit::runner::run_tear_down() { - local _test_file="${1-}" - bashunit::internal_log "run_tear_down" - bashunit::runner::execute_test_hook 'tear_down' -} - -function bashunit::runner::execute_test_hook() { - local hook_name="$1" - - declare -F "$hook_name" >/dev/null 2>&1 || return 0 - - local hook_output="" - local status=0 - local hook_output_file - hook_output_file=$(bashunit::temp_file "${hook_name}_output") - - # Enable errtrace to catch any failing command in the hook. - # Using -E (errtrace) without -e (errexit) prevents the subshell from - # exiting on source failures (Bash 3.2 doesn't trigger ERR trap with -eE). - # The ERR trap saves the exit status to a global variable, cleans up shell - # options, and returns from the hook function to prevent subsequent commands - # from executing. - # Variables set before the failure are preserved since we don't use a subshell. - _BASHUNIT_HOOK_ERR_STATUS=0 - set -E - if bashunit::env::is_strict_mode_enabled; then - set -uo pipefail - fi - # See the twin comment in execute_file_hook: conditional return keeps the - # early-exit semantics for intermediate failures without silently returning - # from THIS executor when the trap re-fires here on Bash >= 4 (#836). - # shellcheck disable=SC2154 - trap '_BASHUNIT_HOOK_ERR_STATUS=$? - if [ "${FUNCNAME[0]:-}" != "bashunit::runner::execute_test_hook" ]; then - set +Eu +o pipefail - trap - ERR - return $_BASHUNIT_HOOK_ERR_STATUS - fi' ERR - - { - "$hook_name" - } >"$hook_output_file" 2>&1 - # Real hook status from $?; the trap global alone misses failing - # `cmd && var=x` guards (&& lists are ERR-exempt) (#836). - status=$? - if [ "$status" -eq 0 ]; then - status=$_BASHUNIT_HOOK_ERR_STATUS - fi - - trap - ERR - set +Eu +o pipefail - - if [ -f "$hook_output_file" ]; then - hook_output="" - local line - while IFS= read -r line; do - [ -z "$hook_output" ] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" - done <"$hook_output_file" - rm -f "$hook_output_file" - fi - - if [ $status -ne 0 ]; then - local message="$hook_output" - if [ -n "$hook_output" ]; then - printf "%s" "$hook_output" - else - message="Hook '$hook_name' failed with exit code $status" - printf "%s\n" "$message" >&2 - fi - bashunit::runner::record_test_hook_failure "$hook_name" "$message" "$status" - return "$status" - fi - - if [ -n "$hook_output" ]; then - printf "%s" "$hook_output" - fi - - return 0 -} - -function bashunit::runner::record_test_hook_failure() { - local hook_name="$1" - local hook_message="$2" - local status="$3" - - if [ -n "$_BASHUNIT_TEST_HOOK_FAILURE" ]; then - return "$status" - fi - - bashunit::state::set_test_hook_failure "$hook_name" - bashunit::state::set_test_hook_message "$hook_message" - - return "$status" -} - -function bashunit::runner::clear_mocks() { - if [ "${#_BASHUNIT_MOCKED_FUNCTIONS[@]}" -eq 0 ]; then - return - fi - - local i - for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do - bashunit::unmock "${_BASHUNIT_MOCKED_FUNCTIONS[$i]:-}" - done -} - -function bashunit::runner::run_tear_down_after_script() { - local test_file="$1" - bashunit::internal_log "run_tear_down_after_script" - - # Check if hook exists first - if ! declare -F "tear_down_after_script" >/dev/null 2>&1; then - # Add blank line after tests if no tear_down hook - if ! bashunit::env::is_simple_output_enabled && - ! bashunit::env::is_failures_only_enabled && - ! bashunit::env::is_no_progress_enabled && - ! bashunit::parallel::is_enabled; then - echo "" - fi - return 0 - fi - - local start_time - start_time=$(bashunit::clock::now) - - # Enable coverage trap to attribute lines executed during tear_down_after_script - if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then - bashunit::coverage::enable_trap - fi - - # Execute the hook - bashunit::runner::execute_file_hook 'tear_down_after_script' "$test_file" - local status=$? - - # Disable coverage trap after hook execution - if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then - bashunit::coverage::disable_trap - fi - - local end_time - end_time=$(bashunit::clock::now) - local duration_ns=$((end_time - start_time)) - local duration_ms=$((duration_ns / 1000000)) - - # Print completion message only if hook succeeded - if [ $status -eq 0 ]; then - bashunit::console_results::print_hook_completed "tear_down_after_script" "$duration_ms" - fi - - # Add blank line after tear_down output - if ! bashunit::env::is_simple_output_enabled && - ! bashunit::env::is_failures_only_enabled && - ! bashunit::env::is_no_progress_enabled && - ! bashunit::parallel::is_enabled; then - echo "" - fi - - return $status -} - -## -# Unset a file's test functions once the file has been processed. -# -# Test files are sourced into the main shell, and their functions used to stay -# defined for the whole run: every test's $() subshell then forked an -# ever-growing shell, making multi-file runs quadratic in file count (#829). -# In parallel mode the file's workers have already forked (with their own copy -# of the functions) by the time this runs, so unsetting here is race-free. -# Arguments: $1 - whitespace-separated test function names -## -function bashunit::runner::clean_script_test_functions() { - local IFS=$' \t\n' - local fn - for fn in $1; do - unset -f "$fn" 2>/dev/null || true - done -} - -function bashunit::runner::clean_set_up_and_tear_down_after_script() { - bashunit::internal_log "clean_set_up_and_tear_down_after_script" - bashunit::helper::unset_if_exists 'set_up' - bashunit::helper::unset_if_exists 'tear_down' - bashunit::helper::unset_if_exists 'set_up_before_script' - bashunit::helper::unset_if_exists 'tear_down_after_script' -} +# Sourced in dependency layers, leaves first: +# context · payload · diagnostics → parallel · hooks · result → provider · exec → discovery · bench +source "$BASHUNIT_ROOT_DIR/src/runner/context.sh" +source "$BASHUNIT_ROOT_DIR/src/runner/payload.sh" +source "$BASHUNIT_ROOT_DIR/src/runner/diagnostics.sh" +source "$BASHUNIT_ROOT_DIR/src/runner/parallel.sh" +source "$BASHUNIT_ROOT_DIR/src/runner/hooks.sh" +source "$BASHUNIT_ROOT_DIR/src/runner/result.sh" +source "$BASHUNIT_ROOT_DIR/src/runner/provider.sh" +source "$BASHUNIT_ROOT_DIR/src/runner/exec.sh" +source "$BASHUNIT_ROOT_DIR/src/runner/discovery.sh" +source "$BASHUNIT_ROOT_DIR/src/runner/bench.sh" diff --git a/src/runner/bench.sh b/src/runner/bench.sh new file mode 100644 index 00000000..dba521d2 --- /dev/null +++ b/src/runner/bench.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +function bashunit::runner::load_bench_files() { + local filter=$1 + shift + local IFS=$' \t\n' + local -a files + files=("$@") + + local bench_file + for bench_file in "${files[@]+"${files[@]}"}"; do + [ -f "$bench_file" ] || continue + unset BASHUNIT_CURRENT_TEST_ID + bashunit::helper::generate_id "${bench_file}" + export BASHUNIT_CURRENT_SCRIPT_ID="$_BASHUNIT_HELPER_ID_OUT" + # shellcheck source=/dev/null + source "$bench_file" + # Reset the loop's shell-mode invariant; a bench file may set -euo at top + # level and sourcing runs that in this shell (see the test loop) (#836). + set +euo pipefail + # Update function cache after sourcing new bench file (compgen is a builtin) + _BASHUNIT_CACHED_ALL_FUNCTIONS=$(compgen -A function) + # Call hook directly (not with `if !`) to preserve errexit behavior inside the hook + bashunit::runner::run_set_up_before_script "$bench_file" + local setup_before_script_status=$? + if [ $setup_before_script_status -ne 0 ]; then + # Count the bench functions that couldn't run due to set_up_before_script failure + # and add them as failed (minus 1 since the hook failure already counts as 1) + local filtered_functions + filtered_functions=$(bashunit::helper::get_functions_to_run "bench" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") + if [ -n "$filtered_functions" ]; then + # Bash 3.0 compatible: separate declaration and assignment for arrays + local functions_to_run + # shellcheck disable=SC2206 + functions_to_run=($filtered_functions) + local additional_failures=$((${#functions_to_run[@]} - 1)) + local i + for ((i = 0; i < additional_failures; i++)); do + bashunit::state::add_tests_failed + done + fi + bashunit::runner::clean_set_up_and_tear_down_after_script + bashunit::cleanup_script_temp_files + bashunit::runner::restore_workdir + continue + fi + bashunit::runner::call_bench_functions "$bench_file" "$filter" + bashunit::runner::run_tear_down_after_script "$bench_file" + bashunit::runner::clean_set_up_and_tear_down_after_script + bashunit::cleanup_script_temp_files + bashunit::runner::restore_workdir + done +} + +function bashunit::runner::call_bench_functions() { + local script="$1" + local filter="$2" + local IFS=$' \t\n' + local prefix="bench" + + # Use cached function names for better performance + local filtered_functions + filtered_functions=$(bashunit::helper::get_functions_to_run \ + "$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") + local -a functions_to_run=() + local functions_to_run_count=0 + local _fn + while IFS= read -r _fn; do + [ -z "$_fn" ] && continue + functions_to_run[functions_to_run_count]="$_fn" + functions_to_run_count=$((functions_to_run_count + 1)) + done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions") + + if [ "$functions_to_run_count" -le 0 ]; then + return + fi + + if bashunit::env::is_bench_mode_enabled; then + bashunit::runner::render_running_file_header "$script" + fi + + local fn_name + for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do + # Capture separately so a malformed annotation aborts the run: the exit + # status of a $(...) inside `read <<<` is otherwise discarded (#884). + local parsed_annotations + parsed_annotations=$(bashunit::benchmark::parse_annotations "$fn_name" "$script") || exit 1 + read -r revs its max_ms <<<"$parsed_annotations" + bashunit::benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" + unset -v fn_name + done + + if ! bashunit::env::is_simple_output_enabled; then + echo "" + fi +} diff --git a/src/runner/context.sh b/src/runner/context.sh new file mode 100644 index 00000000..a3429472 --- /dev/null +++ b/src/runner/context.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +## +# Returns to the directory bashunit was started from, undoing any `cd` a test +# file performed in `set_up_before_script` (#532). +# +# A failure here is not recoverable: every later test file is discovered and +# sourced through a path relative to this directory, so silently staying put +# would drop the remaining files from the run without a single error. Abort +# loudly instead. +# +# Arguments: $1 (optional) directory to restore, defaults to BASHUNIT_WORKING_DIR +## +function bashunit::runner::restore_workdir() { + local target="${1:-${BASHUNIT_WORKING_DIR:-}}" + if cd "$target" 2>/dev/null; then + return 0 + fi + + printf "%sError: cannot restore the working directory '%s'. Aborting run.%s\n" \ + "${_BASHUNIT_COLOR_FAILED:-}" "$target" "${_BASHUNIT_COLOR_DEFAULT:-}" >&2 + exit 1 +} + +## +# Whether the running Bash has a reliable `set -o pipefail`. Bash 3.0 shipped a +# broken pipefail (a failing pipeline can wrongly report success), which makes +# `--strict` unsound; on 3.0 we fall back to `set -eu` without pipefail. +# Returns: 0 when pipefail is reliable (Bash >= 3.1), 1 otherwise. +## +function bashunit::runner::_supports_reliable_pipefail() { + if [ "${BASH_VERSINFO[0]:-0}" -gt 3 ]; then + return 0 + fi + [ "${BASH_VERSINFO[0]:-0}" -eq 3 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 1 ] +} + +# Caches BASHUNIT_COVERAGE into _BASHUNIT_COVERAGE_ON ("1"|"0") so hot-path checks +# avoid a function dispatch per call. Call once after arg parsing; tests that +# toggle BASHUNIT_COVERAGE mid-run must call this again to refresh. +function bashunit::runner::sync_coverage_flag() { + if [ "${BASHUNIT_COVERAGE-}" = "true" ]; then + _BASHUNIT_COVERAGE_ON=1 + else + _BASHUNIT_COVERAGE_ON=0 + fi +} + +function bashunit::runner::source_login_shell_profiles() { + # shellcheck disable=SC1091 + [ -f /etc/profile ] && source /etc/profile 2>/dev/null || true + # shellcheck disable=SC1090 + [ -f ~/.bash_profile ] && source ~/.bash_profile 2>/dev/null || true + # shellcheck disable=SC1090 + [ -f ~/.bash_login ] && source ~/.bash_login 2>/dev/null || true + # shellcheck disable=SC1090 + [ -f ~/.profile ] && source ~/.profile 2>/dev/null || true +} + +function bashunit::runner::export_test_identity() { + local test_file=$1 + local fn_name=$2 + bashunit::helper::generate_id "$fn_name" + export BASHUNIT_CURRENT_TEST_ID="$_BASHUNIT_HELPER_ID_OUT" + bashunit::runner::resolve_test_location "$test_file" "$fn_name" + export _BASHUNIT_TEST_LOCATION + if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then + export _BASHUNIT_COVERAGE_CURRENT_TEST_FILE="$test_file" + export _BASHUNIT_COVERAGE_CURRENT_TEST_FN="$fn_name" + fi +} + +## +# Resolves ":" for a test function and writes it into the +# global _BASHUNIT_TEST_LOCATION, using `declare -F` under `extdebug` to read +# the definition line. Falls back to just the file path when the line cannot be +# determined. Bash 3.0+ compatible. Writes a global slot (no extra subshell). +# Arguments: $1 test file, $2 function name +## +function bashunit::runner::resolve_test_location() { + local test_file=$1 + local fn_name=$2 + + # Enable extdebug only inside the command-substitution subshell so it never + # leaks into the parent shell — globally toggling extdebug interferes with + # `set -e`/DEBUG-trap behavior under --strict. + local def line="" + def="$(shopt -s extdebug; declare -F "$fn_name" 2>/dev/null)" || true + + # `declare -F` (with extdebug) prints " ". + if [ -n "$def" ]; then + line=${def#* } + line=${line%% *} + fi + + if [ -n "$line" ]; then + _BASHUNIT_TEST_LOCATION="${test_file}:${line}" + else + _BASHUNIT_TEST_LOCATION="$test_file" + fi +} + +# Writes the interpolated test-function name into _BASHUNIT_RUNNER_INTERP_OUT. +# Arguments: $1 fn_name, $@ test arguments +function bashunit::runner::apply_interpolated_title() { + local fn_name=$1 + shift + + # Only "::N::"-style names interpolate; skip the capture fork for the rest. + case "$fn_name" in + *::*) ;; + *) + bashunit::state::reset_current_test_interpolated_function_name + _BASHUNIT_RUNNER_INTERP_OUT=$fn_name + return + ;; + esac + + local interpolated + interpolated="$(bashunit::helper::interpolate_function_name "$fn_name" "$@")" + if [ "$interpolated" != "$fn_name" ]; then + bashunit::state::set_current_test_interpolated_function_name "$interpolated" + else + bashunit::state::reset_current_test_interpolated_function_name + fi + _BASHUNIT_RUNNER_INTERP_OUT=$interpolated +} + +# Per-test duration is consumed by --profile, --verbose, report files, and the +# execution-time display. When none are active we can skip the clock reads, +# which matters when the clock forks an interpreter (#765). +function bashunit::runner::needs_test_duration() { + bashunit::env::is_profile_enabled && return 0 + bashunit::env::is_verbose_enabled && return 0 + bashunit::reports::is_enabled && return 0 + bashunit::env::is_show_execution_time_enabled && return 0 + return 1 +} diff --git a/src/runner/diagnostics.sh b/src/runner/diagnostics.sh new file mode 100644 index 00000000..7f8be78d --- /dev/null +++ b/src/runner/diagnostics.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +## +# Appends a profiling record (duration, test name, file) to PROFILE_OUTPUT_PATH. +# Uses a tab-separated, append-only line so it aggregates correctly across the +# subshells spawned by parallel runs. +# Arguments: $1 duration (ms), $2 test name, $3 test file +## +function bashunit::runner::record_profile() { + local duration=$1 + local test_name=$2 + local test_file=$3 + printf '%s\t%s\t%s\n' "$duration" "$test_name" "$test_file" >>"$PROFILE_OUTPUT_PATH" +} + +## +# Honours --stop-on-failure once a test has been recorded as failed. A parallel +# worker raises the shared flag file (the dispatcher checks it between tests) +# rather than exiting, since exiting would only kill the worker. A sequential +# run exits with EXIT_CODE_STOP_ON_FAILURE, which main.sh's EXIT trap turns +# into the final summary. No-op when the flag is off. +## +function bashunit::runner::halt_if_stop_on_failure() { + bashunit::env::is_stop_on_failure_enabled || return 0 + + if bashunit::parallel::is_enabled; then + bashunit::parallel::mark_stop_on_failure + else + exit "$EXIT_CODE_STOP_ON_FAILURE" + fi +} + +# Writes the detected runtime-error message (empty when none) into +# _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT. Return-slot form avoids a per-test fork +# on the hot path (#764). +# Arguments: $1 runtime_output +function bashunit::runner::detect_runtime_error() { + local runtime_output=$1 + _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" + case "$runtime_output" in + *"command not found"* | *"unbound variable"* | *"permission denied"* | \ + *"no such file or directory"* | *"syntax error"* | *"bad substitution"* | \ + *"division by 0"* | *"cannot allocate memory"* | *"bad file descriptor"* | \ + *"segmentation fault"* | *"illegal option"* | *"argument list too long"* | \ + *"readonly variable"* | *"missing keyword"* | *"killed"* | \ + *"cannot execute binary file"* | *"invalid arithmetic operator"* | \ + *"ambiguous redirect"* | *"integer expression expected"* | \ + *"too many arguments"* | *"value too great"* | \ + *"not a valid identifier"* | *"unexpected EOF"*) + local runtime_error="${runtime_output#*: }" + _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="${runtime_error//$'\n'/}" + ;; + esac +} + +## +# Maps a process exit code to a human-readable description when it indicates the +# test was killed by a signal (128 + signal) or timed out. Returns an empty +# string for ordinary exit codes. Bash 3.0+ compatible. +# Arguments: $1 exit code +## +function bashunit::runner::classify_kill_signal() { + local code=$1 + + case "$code" in + 124) printf 'Timed out (killed by `timeout`)' ;; + 130) printf 'Interrupted (SIGINT)' ;; + 137) printf 'Killed (SIGKILL — out of memory or forced termination)' ;; + 143) printf 'Terminated (SIGTERM — e.g. a timeout)' ;; + *) + # Generic "killed by signal N" for other 128+N codes (signals 1..64) + case "$code" in + '' | *[!0-9]*) return 0 ;; + esac + if [ "$code" -gt 128 ] && [ "$code" -le 192 ]; then + printf 'Killed by signal %s' "$((code - 128))" + fi + ;; + esac +} + +function bashunit::runner::print_verbose_test_summary() { + local test_file=$1 + local fn_name=$2 + local duration=$3 + local test_execution_result=$4 + + if bashunit::env::is_simple_output_enabled; then + echo "" + fi + + printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '=' + printf "%s\n" "File: $test_file" + printf "%s\n" "Function: $fn_name" + printf "%s\n" "Duration: $duration ms" + local raw_text=${test_execution_result%%##ASSERTIONS_*} + [ -n "$raw_text" ] && printf "%s" "Raw text: $raw_text" + printf "%s\n" "##ASSERTIONS_${test_execution_result#*##ASSERTIONS_}" + printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '-' +} + +function bashunit::runner::render_running_file_header() { + local script="$1" + local force="${2:-false}" + + bashunit::internal_log "Running file" "$script" + + if [ "$force" != true ] && bashunit::parallel::is_enabled; then + return + fi + + # Suppress file headers in failures-only mode + if bashunit::env::is_failures_only_enabled; then + return + fi + + # Suppress file headers in no-progress mode + if bashunit::env::is_no_progress_enabled; then + return + fi + + if bashunit::env::is_tap_output_enabled; then + printf "# %s\n" "$script" + elif ! bashunit::env::is_simple_output_enabled; then + if bashunit::env::is_verbose_enabled; then + printf "\n${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" "Running $script" + else + printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" "Running $script" + fi + elif bashunit::env::is_verbose_enabled; then + printf "\n\n${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}" "Running $script" + fi +} diff --git a/src/runner/discovery.sh b/src/runner/discovery.sh new file mode 100644 index 00000000..15c76650 --- /dev/null +++ b/src/runner/discovery.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash + +function bashunit::runner::load_test_files() { + local filter=$1 + local tag_filter="${2:-}" + local exclude_tag_filter="${3:-}" + shift 3 + local IFS=$' \t\n' + local -a files + files=("$@") + local -a scripts_ids=() + local scripts_ids_count=0 + local -a worker_stderr_paths=() + local -a worker_stderr_owners=() + local worker_stderr_count=0 + + # Randomize file execution order (deterministic for the resolved seed). + if bashunit::env::is_random_order_enabled; then + local -a _shuffled_files=() + local _sf + while IFS= read -r _sf; do + [ -n "$_sf" ] && _shuffled_files[${#_shuffled_files[@]}]=$_sf + done < <(printf '%s\n' "${files[@]+"${files[@]}"}" | bashunit::math::shuffle "$(bashunit::env::seed)") + files=("${_shuffled_files[@]+"${_shuffled_files[@]}"}") + fi + + bashunit::runner::sync_coverage_flag + + # Initialize coverage tracking if enabled + if [ "$_BASHUNIT_COVERAGE_ON" = 1 ]; then + # Auto-discover coverage paths if not explicitly set + if [ -z "$BASHUNIT_COVERAGE_PATHS" ]; then + BASHUNIT_COVERAGE_PATHS=$(bashunit::coverage::auto_discover_paths "${files[@]}") + # Fallback: if auto-discovery yields no paths, track the src/ folder + if [ -z "$BASHUNIT_COVERAGE_PATHS" ]; then + BASHUNIT_COVERAGE_PATHS="src/" + fi + fi + bashunit::coverage::init + fi + + local test_file + for test_file in "${files[@]+"${files[@]}"}"; do + if [ ! -f "$test_file" ]; then + continue + fi + unset BASHUNIT_CURRENT_TEST_ID + bashunit::helper::generate_id "${test_file}" + export BASHUNIT_CURRENT_SCRIPT_ID="$_BASHUNIT_HELPER_ID_OUT" + scripts_ids[scripts_ids_count]="${BASHUNIT_CURRENT_SCRIPT_ID}" + scripts_ids_count=$((scripts_ids_count + 1)) + bashunit::internal_log "Loading file" "$test_file" + # Files are sourced sequentially in this loop (parallel workers fork after), + # so a fixed path in the run dir is safe: `2>` truncates it per file and the + # run-dir cleanup removes it, saving a mktemp and an rm fork per file. + local source_err_file source_err source_status + source_err_file="$_BASHUNIT_RUN_OUTPUT_DIR/source_err" + # shellcheck source=/dev/null + source "$test_file" 2>"$source_err_file" + source_status=$? + # A test file may enable `set -euo pipefail` at its top level; sourcing + # runs that in THIS shell, so a later non-zero status in the loop (e.g. a + # failing set_up_before_script) would kill the whole run mid-suite with no + # summary. Strictness is applied per-test in execute_test_body — reset the + # runner loop to its set +euo invariant (see main.sh exec_tests) (#836). + set +euo pipefail + source_err="" + if [ -s "$source_err_file" ]; then + source_err="$(cat "$source_err_file")" + fi + # A non-zero source status, or a syntax-error line on stderr, means the file + # failed to load. Match the captured stderr with `case` (no grep fork). + local source_failed=false + if [ "$source_status" -ne 0 ]; then + source_failed=true + else + case "$source_err" in + *"syntax error"* | *"unexpected EOF"*) source_failed=true ;; + esac + fi + if [ "$source_failed" = true ]; then + local message="$source_err" + [ -z "$message" ] && message="Failed to source '$test_file' (exit $source_status)" + bashunit::runner::record_file_hook_failure \ + "source" "$test_file" "$message" 1 true + bashunit::runner::clean_set_up_and_tear_down_after_script + bashunit::runner::restore_workdir + continue + fi + # Update function cache after sourcing new test file (compgen is a builtin) + _BASHUNIT_CACHED_ALL_FUNCTIONS=$(compgen -A function) + # Check if any tests match the filter before rendering header or running hooks + local filtered_functions + filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") + local functions_for_script + functions_for_script=$(bashunit::runner::functions_for_script "$test_file" "$filtered_functions") + # Full pre-tag/rerun list: these are unset once the file has been + # processed, whatever subset actually runs (#829). + local _script_fns_to_clean="$functions_for_script" + # Apply tag filtering to the early check as well + if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then + bashunit::helper::build_tags_map "$test_file" + local _early_filtered="" + local _early_fn + for _early_fn in $functions_for_script; do + bashunit::helper::tags_for_function "$_early_fn" + if bashunit::helper::function_matches_tags "$_BASHUNIT_TAGS_OUT" "$tag_filter" "$exclude_tag_filter"; then + _early_filtered="$_early_filtered $_early_fn" + fi + done + functions_for_script="${_early_filtered# }" + fi + # Replay filtering: keep only the functions recorded as failing last run. + if bashunit::rerun::is_enabled && bashunit::rerun::has_entries; then + functions_for_script=$(bashunit::rerun::filter_functions "$test_file" "$functions_for_script") + fi + if [ -z "$functions_for_script" ]; then + bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" + bashunit::runner::clean_set_up_and_tear_down_after_script + bashunit::runner::restore_workdir + continue + fi + # Render header BEFORE set_up_before_script so user sees activity immediately + bashunit::runner::render_running_file_header "$test_file" + # Call hook directly (not with `if !`) to preserve errexit behavior inside the hook + bashunit::runner::run_set_up_before_script "$test_file" + local setup_before_script_status=$? + if [ $setup_before_script_status -ne 0 ]; then + # Count the test functions that couldn't run due to set_up_before_script + # failure and add them as failed (minus 1 since the hook failure already + # counts as 1). Use this file's own function list — scanning the cached + # ALL-functions set would also count fns left over from earlier files + # and inflate the totals (#836). + if [ -n "$functions_for_script" ]; then + # Bash 3.0 compatible: separate declaration and assignment for arrays + local functions_to_run + # shellcheck disable=SC2206 + functions_to_run=($functions_for_script) + local additional_failures=$((${#functions_to_run[@]} - 1)) + local i + for ((i = 0; i < additional_failures; i++)); do + bashunit::state::add_tests_failed + done + fi + # Same cleanup as the success path: without it the file's test functions + # leak into the next iteration's counts and the main shell (#829, #836). + bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" + bashunit::runner::clean_set_up_and_tear_down_after_script + if ! bashunit::parallel::is_enabled; then + bashunit::cleanup_script_temp_files + fi + bashunit::runner::restore_workdir + continue + fi + local _cached_fns="$functions_for_script" + if bashunit::parallel::is_enabled; then + bashunit::runner::wait_for_job_slot + # Capture rather than discard: a worker's stderr cannot be written + # straight to the terminal without shredding the progress line, but + # dropping it made the same run report differently under --parallel + # (#358 added the discard, #864 replaced it with this capture). + local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}" + worker_stderr_paths[worker_stderr_count]="$_worker_stderr" + worker_stderr_owners[worker_stderr_count]="$test_file" + worker_stderr_count=$((worker_stderr_count + 1)) + bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" & + else + bashunit::runner::call_test_functions "$test_file" "$_cached_fns" + fi + bashunit::runner::run_tear_down_after_script "$test_file" + bashunit::runner::clean_script_test_functions "$_script_fns_to_clean" + bashunit::runner::clean_set_up_and_tear_down_after_script + if ! bashunit::parallel::is_enabled; then + bashunit::cleanup_script_temp_files + fi + bashunit::internal_log "Finished file" "$test_file" + bashunit::runner::restore_workdir + done + + if bashunit::parallel::is_enabled; then + wait + bashunit::runner::spinner & + local spinner_pid=$! + bashunit::state::aggregate_parallel_results "$TEMP_DIR_PARALLEL_TEST_SUITE" + # Kill the spinner once the aggregation finishes + disown "$spinner_pid" 2>/dev/null || true + kill "$spinner_pid" 2>/dev/null || true + printf "\r \r" # Clear the spinner output + + local _stderr_idx=0 + while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do + if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then + bashunit::console_results::print_worker_stderr \ + "${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}" + fi + _stderr_idx=$((_stderr_idx + 1)) + done + + local script_id + for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do + export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}" + bashunit::cleanup_script_temp_files + done + fi +} + +function bashunit::runner::functions_for_script() { + local script="$1" + local all_fn_names="$2" + + # Resolve " " for the given names, enabling extdebug only + # inside the capture subshell so the caller's setting is untouched. + local declarations + # shellcheck disable=SC2086 + declarations=$( + shopt -s extdebug + declare -F $all_fn_names 2>/dev/null + ) + + # Keep the functions defined in this script, insertion-sorted by definition + # line. Pure bash: the old `awk | sort | awk` pipeline cost three forks and + # ran twice per file, while a file's function list is small (tens of names). + local -a fns=() + local -a fn_lines=() + local count=0 + local name line file i + while read -r name line file; do + [ "$file" = "$script" ] || continue + i=$count + while [ "$i" -gt 0 ] && [ "${fn_lines[i - 1]}" -gt "$line" ]; do + fns[i]=${fns[i - 1]} + fn_lines[i]=${fn_lines[i - 1]} + i=$((i - 1)) + done + fns[i]=$name + fn_lines[i]=$line + count=$((count + 1)) + done </dev/null || true + fi + + for fn_name in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do + if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then + break + fi + + # No data provider found: run once without forking to capture provider output. + bashunit::helper::provider_for_function "$fn_name" + if [ -z "$_BASHUNIT_PROVIDER_FN_OUT" ]; then + if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then + bashunit::runner::wait_for_job_slot + _test_ordinal=$((_test_ordinal + 1)) + _BASHUNIT_RUNNER_RESULT_ORDINAL=$_test_ordinal + bashunit::runner::run_test "$script" "$fn_name" & + else + bashunit::runner::run_test "$script" "$fn_name" + fi + unset -v fn_name + continue + fi + + provider_data=() + provider_data_count=0 + local line + while IFS=" " read -r line; do + [ -z "$line" ] && continue + provider_data[provider_data_count]="$line" + provider_data_count=$((provider_data_count + 1)) + done <<<"$(bashunit::helper::execute_function_if_exists "$_BASHUNIT_PROVIDER_FN_OUT")" + + # Execute the test function for each line of data + local data + for data in "${provider_data[@]+"${provider_data[@]}"}"; do + parsed_data=() + parsed_data_count=0 + local line + while IFS= read -r line; do + [ -z "$line" ] && continue + parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")" + parsed_data_count=$((parsed_data_count + 1)) + done <<<"$(bashunit::runner::parse_data_provider_args "$data")" + if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then + bashunit::runner::wait_for_job_slot + _test_ordinal=$((_test_ordinal + 1)) + _BASHUNIT_RUNNER_RESULT_ORDINAL=$_test_ordinal + bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} & + else + bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} + fi + done + unset -v fn_name + done + + # Wait for all parallel tests within this file to complete + if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then + wait + fi +} + +# Result slots for the timeout-aware execution path (see run_with_timeout). +_BASHUNIT_RUNNER_EXEC_OUT="" +_BASHUNIT_RUNNER_TIMED_OUT="false" + +## +# Runs a single test inside the capture subshell: sets up the EXIT trap that +# encodes assertion counts/exit code, runs set_up, applies the shell mode and +# finally invokes the test function. Meant to be called from a subshell (either +# the `$(...)` capture or a backgrounded job), so its `set`/`trap`/`exit` calls +# stay isolated. Emits the test stdout (with stderr merged) followed by the +# encoded context from cleanup_on_exit. +# Arguments: $1 test file, $2 function name, $@ test args +## +function bashunit::runner::execute_test_body() { + local test_file=$1 + shift + local fn_name=$1 + shift + + # Save subshell stdout to FD 5 so the EXIT trap can restore it. + # When set -e kills the subshell during a redirected block in + # execute_test_hook, the redirect leaks into the EXIT trap, + # causing export_subshell_context output to be lost. + exec 5>&1 + # shellcheck disable=SC2064 + # shellcheck disable=SC2154 # assigned inside the trap body, read by cleanup_on_exit (runner/hooks.sh) + trap "exit_code=\$?; bashunit::runner::cleanup_on_exit \"$test_file\" \"\$exit_code\"" EXIT + bashunit::state::initialize_assertions_count + + if bashunit::env::is_login_shell_enabled; then + bashunit::runner::source_login_shell_profiles + fi + + # Enable coverage tracking early to include set_up/tear_down hooks + if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then + bashunit::coverage::enable_trap + fi + + # Run set_up and capture exit code without || to preserve errexit behavior + # shellcheck disable=SC2030 + _BASHUNIT_SETUP_COMPLETED=false + local setup_exit_code=0 + bashunit::runner::run_set_up "$test_file" + setup_exit_code=$? + _BASHUNIT_SETUP_COMPLETED=true + if [ $setup_exit_code -ne 0 ]; then + exit $setup_exit_code + fi + + # Apply shell mode setting for test execution + if bashunit::env::is_strict_mode_enabled; then + set -eu + # Bash 3.0 ships a broken pipefail; only enable it where it is reliable. + if bashunit::runner::_supports_reliable_pipefail; then + set -o pipefail + else + set +o pipefail + fi + else + set +euo pipefail + fi + + # 2>&1: Redirects the std-error (FD 2) to the std-output (FD 1). + # points to the original std-output. + "$fn_name" "$@" 2>&1 +} + +## +# Prints an encoded subshell result for a test that timed out: empty assertion +# counters and exit code 124 (the conventional "timed out" code, already mapped +# by classify_kill_signal). The empty TEST_HOOK_MESSAGE/TITLE/OUTPUT fields would +# base64-encode to an empty string anyway, so the line is emitted directly rather +# than mutating the shared _BASHUNIT_* globals (it mirrors the layout produced by +# bashunit::state::export_subshell_context). Bash 3.0+ compatible. +## +function bashunit::runner::build_timeout_result() { + printf '%s' "##ASSERTIONS_FAILED=0##ASSERTIONS_PASSED=0##ASSERTIONS_SKIPPED=0\ +##ASSERTIONS_INCOMPLETE=0##ASSERTIONS_SNAPSHOT=0##TEST_EXIT_CODE=124\ +##TEST_HOOK_FAILURE=##TEST_HOOK_MESSAGE=##TEST_TITLE=##TEST_OUTPUT=##" +} + +## +# Runs the test body with a watchdog that kills it after BASHUNIT_TEST_TIMEOUT +# seconds. The body runs as a backgrounded job in its own process group (set -m) +# so the watchdog can SIGTERM/SIGKILL the whole tree — a hanging test usually +# blocks in a child process, which signalling the subshell alone cannot reach. +# Writes the captured result to _BASHUNIT_RUNNER_EXEC_OUT and "true"/"false" to +# _BASHUNIT_RUNNER_TIMED_OUT. Bash 3.0+ compatible (validated on Bash 3.2). +# Arguments: $1 test file, $2 function name, $@ test args +## +function bashunit::runner::run_with_timeout() { + local test_file=$1 + shift + local fn_name=$1 + shift + local secs + secs=$(bashunit::env::test_timeout_secs) + + # NOTE: these must NOT use bashunit::temp_file — that prefixes the current + # test id, and cleanup_on_exit (run inside the test subshell) would unlink + # them via cleanup_testcase_temp_files before we read them back here. + local tmp_dir="${BASHUNIT_TEMP_DIR:-${TMPDIR:-/tmp}}" + local out_file marker_file + out_file="$("$MKTEMP" "$tmp_dir/bashunit_timeout_out.XXXXXXX")" + marker_file="$("$MKTEMP" "$tmp_dir/bashunit_timeout_marker.XXXXXXX")" + rm -f "$marker_file" + + # Both jobs run in their own process group (set -m) so each can be killed as a + # whole tree. The body MUST run in an explicit ( ) subshell: a backgrounded { } + # group does not run its EXIT trap on normal completion, which would drop the + # encoded assertion context. The watchdog's fds are detached from the caller so + # a lingering `sleep` can never hold a captured stdout pipe open. + set -m + (bashunit::runner::execute_test_body "$test_file" "$fn_name" "$@") >"$out_file" 2>&1 & + local test_pid=$! + ( + sleep "$secs" + # Only a still-running test can have timed out. Without this guard a watchdog + # that outlived a missed teardown (see below) would mark an already-finished + # fast test as timed out. + kill -0 "$test_pid" 2>/dev/null || exit 0 + : >"$marker_file" + kill -TERM -"$test_pid" 2>/dev/null + sleep 0.3 + kill -KILL -"$test_pid" 2>/dev/null + ) /dev/null 2>&1 & + local watchdog_pid=$! + set +m + + wait "$test_pid" 2>/dev/null + # Stop the watchdog by its pid AND its group. `set -m` does not reliably make a + # backgrounded subshell a group leader in a non-interactive shell, so the + # group-only kill intermittently misses, letting the watchdog sleep its full + # timeout and fire against a test that already passed. The direct-pid signal is + # always deliverable; the group signal also reaps the `sleep` child. + kill -TERM "$watchdog_pid" 2>/dev/null + kill -TERM -"$watchdog_pid" 2>/dev/null + wait "$watchdog_pid" 2>/dev/null + + if [ -f "$marker_file" ]; then + _BASHUNIT_RUNNER_TIMED_OUT="true" + _BASHUNIT_RUNNER_EXEC_OUT="$(bashunit::runner::build_timeout_result)" + else + _BASHUNIT_RUNNER_TIMED_OUT="false" + _BASHUNIT_RUNNER_EXEC_OUT="$(cat "$out_file" 2>/dev/null)" + fi + + rm -f "$out_file" "$marker_file" +} + +function bashunit::runner::run_test() { + local start_time=0 + + local test_file="$1" + shift + local fn_name="$1" + shift + + bashunit::internal_log "Running test" "$fn_name" "$*" + bashunit::runner::export_test_identity "$test_file" "$fn_name" + + bashunit::state::reset_test_title + bashunit::runner::apply_interpolated_title "$fn_name" "$@" + local interpolated_fn_name=$_BASHUNIT_RUNNER_INTERP_OUT + local current_assertions_failed="$_BASHUNIT_ASSERTIONS_FAILED" + local current_assertions_snapshot="$_BASHUNIT_ASSERTIONS_SNAPSHOT" + local current_assertions_incomplete="$_BASHUNIT_ASSERTIONS_INCOMPLETE" + local current_assertions_skipped="$_BASHUNIT_ASSERTIONS_SKIPPED" + + # (FD = File Descriptor) + # Duplicate the current std-output (FD 1) and assigns it to FD 3. + # This means that FD 3 now points to wherever the std-output was pointing. + exec 3>&1 + + local test_execution_result + local timed_out="false" + bashunit::env::resolve_retry_count + local retry_max=$_BASHUNIT_RETRY_VALIDATED + local retries_used=0 + local measure_duration=false + bashunit::runner::needs_test_duration && measure_duration=true + # Retry wraps ONLY execution: a failed attempt is judged from its encoded + # result without committing, so the parse/report/counter path below still runs + # exactly once (on the final attempt) and nothing is double-counted. Each fork + # in --parallel retries itself before writing its single .result file. + while :; do + if [ "$measure_duration" = true ]; then + bashunit::clock::now_to_slot + start_time=$_BASHUNIT_CLOCK_NOW_OUT + fi + if bashunit::env::is_test_timeout_enabled; then + bashunit::runner::run_with_timeout "$test_file" "$fn_name" "$@" + test_execution_result="$_BASHUNIT_RUNNER_EXEC_OUT" + timed_out="$_BASHUNIT_RUNNER_TIMED_OUT" + else + test_execution_result=$(bashunit::runner::execute_test_body "$test_file" "$fn_name" "$@") + fi + + local attempt_runtime_output="${test_execution_result%%##ASSERTIONS_*}" + bashunit::runner::detect_runtime_error "$attempt_runtime_output" + local attempt_runtime_error=$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT + bashunit::runner::extract_result_counts "$test_execution_result" + # Mirror the commit-phase failure test exactly (runtime error, non-zero exit, + # or a failed assertion); snapshot/incomplete/skipped/risky are not failures. + if [ -z "$attempt_runtime_error" ] && + [ "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" -eq 0 ] && + [ "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" -eq 0 ]; then + break + fi + [ "$retries_used" -ge "$retry_max" ] && break + retries_used=$((retries_used + 1)) + done + + # Closes FD 3, which was used temporarily to hold the original stdout. + exec 3>&- + + local duration=0 + if [ "$measure_duration" = true ]; then + bashunit::clock::now_to_slot + local end_time=$_BASHUNIT_CLOCK_NOW_OUT + duration=$(((end_time - start_time) / 1000000)) + fi + + if bashunit::env::is_profile_enabled; then + bashunit::runner::record_profile "$duration" "$interpolated_fn_name" "$test_file" + fi + + if bashunit::env::is_verbose_enabled; then + bashunit::runner::print_verbose_test_summary \ + "$test_file" "$fn_name" "$duration" "$test_execution_result" + fi + + bashunit::runner::decode_subshell_output "$test_execution_result" + local subshell_output=$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT + + if [ -n "$subshell_output" ]; then + bashunit::runner::extract_subshell_type "$subshell_output" + local type=$_BASHUNIT_RUNNER_TYPE_OUT + bashunit::runner::format_subshell_output "$subshell_output" + subshell_output=$_BASHUNIT_RUNNER_OUTPUT_OUT + if ! bashunit::env::is_failures_only_enabled; then + bashunit::console_results::print_line "$type" "$subshell_output" + fi + fi + + # Reuse the final attempt's values (the loop always runs at least once and + # its locals persist in this function scope), instead of recomputing and + # forking detect_runtime_error a second time (#764). + local runtime_output=$attempt_runtime_output + local runtime_error=$attempt_runtime_error + + # parse_result accumulates _BASHUNIT_TEST_EXIT_CODE; reset it so each test's + # exit code is read in isolation (a non-zero/timed-out test must not poison + # the next one). + _BASHUNIT_TEST_EXIT_CODE=0 + bashunit::runner::parse_result "$fn_name" "$test_execution_result" "$@" + + local test_exit_code="$_BASHUNIT_TEST_EXIT_CODE" + + bashunit::runner::compute_total_assertions "$test_execution_result" + local total_assertions=$_BASHUNIT_RUNNER_TOTAL_OUT + + bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_TITLE" + local encoded_test_title=$_BASHUNIT_RUNNER_FIELD_OUT + bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_HOOK_FAILURE" + local hook_failure=$_BASHUNIT_RUNNER_FIELD_OUT + bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_HOOK_MESSAGE" + local encoded_hook_message=$_BASHUNIT_RUNNER_FIELD_OUT + + local test_title="" + [ -n "$encoded_test_title" ] && test_title="$(bashunit::helper::decode_base64 "$encoded_test_title")" + local hook_message="" + [ -n "$encoded_hook_message" ] && hook_message="$(bashunit::helper::decode_base64 "$encoded_hook_message")" + + bashunit::set_test_title "$test_title" + bashunit::helper::normalize_test_function_name_to_slot "$fn_name" "$interpolated_fn_name" + local label=$_BASHUNIT_HELPER_NORMALIZED_OUT + bashunit::state::reset_test_title + bashunit::state::reset_current_test_interpolated_function_name + + local failure_label="$label" + local failure_function="$fn_name" + if [ -n "$hook_failure" ]; then + bashunit::helper::normalize_test_function_name_to_slot "$hook_failure" + failure_label=$_BASHUNIT_HELPER_NORMALIZED_OUT + failure_function="$hook_failure" + fi + + if [ -n "$runtime_error" ] || [ "$test_exit_code" -ne 0 ]; then + bashunit::state::add_tests_failed + bashunit::rerun::record "$test_file" "$fn_name" + local error_message="$runtime_error" + if [ -n "$hook_failure" ] && [ -n "$hook_message" ]; then + error_message="$hook_message" + elif [ -z "$error_message" ] && [ -n "$hook_message" ]; then + error_message="$hook_message" + fi + + # When the test was killed by a signal (or timed out), replace an empty or + # generic "Killed" message with a specific cause. + if [ -z "$hook_failure" ]; then + local kill_message + kill_message=$(bashunit::runner::classify_kill_signal "$test_exit_code") + if [ -n "$kill_message" ]; then + case "$error_message" in + '' | *[Kk]illed* | *[Tt]erminated*) error_message="$kill_message" ;; + esac + fi + fi + + # A test that exceeded BASHUNIT_TEST_TIMEOUT gets a clear, specific message. + if [ "$timed_out" = "true" ]; then + error_message="Test timed out after $(bashunit::env::test_timeout_secs)s" + fi + + bashunit::console_results::print_error_test "$failure_function" "$error_message" "$runtime_output" + bashunit::reports::add_test_failed "$test_file" "$failure_label" "$duration" "$total_assertions" "$error_message" + bashunit::runner::write_failure_result_output "$test_file" "$failure_function" "$error_message" "$runtime_output" + bashunit::internal_log "Test error" "$failure_label" "$error_message" + + bashunit::runner::halt_if_stop_on_failure + return + fi + + if [ "$current_assertions_failed" != "$_BASHUNIT_ASSERTIONS_FAILED" ]; then + bashunit::state::add_tests_failed + bashunit::rerun::record "$test_file" "$fn_name" + bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$subshell_output" + local assertion_runtime_output + assertion_runtime_output="$( + bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$subshell_output" + )" + bashunit::runner::write_failure_result_output \ + "$test_file" "$fn_name" "$subshell_output" "$assertion_runtime_output" + + bashunit::internal_log "Test failed" "$label" + + bashunit::runner::halt_if_stop_on_failure + return + fi + + if [ "$current_assertions_snapshot" != "$_BASHUNIT_ASSERTIONS_SNAPSHOT" ]; then + bashunit::state::add_tests_snapshot + # In failures-only mode, suppress snapshot test output + if ! bashunit::env::is_failures_only_enabled; then + bashunit::console_results::print_snapshot_test "$label" + fi + bashunit::reports::add_test_snapshot "$test_file" "$label" "$duration" "$total_assertions" + bashunit::internal_log "Test snapshot" "$label" + return + fi + + if [ "$current_assertions_incomplete" != "$_BASHUNIT_ASSERTIONS_INCOMPLETE" ]; then + bashunit::state::add_tests_incomplete + bashunit::reports::add_test_incomplete "$test_file" "$label" "$duration" "$total_assertions" + bashunit::runner::write_incomplete_result_output "$test_file" "$fn_name" "$subshell_output" + bashunit::internal_log "Test incomplete" "$label" + return + fi + + if [ "$current_assertions_skipped" != "$_BASHUNIT_ASSERTIONS_SKIPPED" ]; then + bashunit::state::add_tests_skipped + bashunit::reports::add_test_skipped "$test_file" "$label" "$duration" "$total_assertions" + bashunit::runner::write_skipped_result_output "$test_file" "$fn_name" "$subshell_output" + bashunit::internal_log "Test skipped" "$label" + return + fi + + # Check for risky test (zero assertions) + if [ "$total_assertions" -eq 0 ]; then + if bashunit::env::is_fail_on_risky_enabled; then + local risky_msg="Test has no assertions (risky)" + bashunit::state::add_tests_failed + bashunit::rerun::record "$test_file" "$fn_name" + bashunit::console_results::print_error_test "$fn_name" "$risky_msg" + bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$risky_msg" + bashunit::runner::write_failure_result_output "$test_file" "$fn_name" "$risky_msg" + bashunit::internal_log "Test failed (risky)" "$label" + bashunit::runner::halt_if_stop_on_failure + return + fi + bashunit::state::add_tests_risky + if ! bashunit::env::is_failures_only_enabled; then + bashunit::console_results::print_risky_test "${label}" "$duration" + fi + bashunit::reports::add_test_risky "$test_file" "$label" "$duration" "$total_assertions" + bashunit::runner::write_risky_result_output "$test_file" "$fn_name" + bashunit::internal_log "Test risky" "$label" + return + fi + + # A test that only passed after retrying is annotated so flakiness stays visible. + _BASHUNIT_RETRY_NOTE="" + if [ "$retries_used" -gt 0 ]; then + _BASHUNIT_RETRY_NOTE=" (retry $retries_used/$retry_max)" + fi + # In failures-only mode, suppress successful test output + if ! bashunit::env::is_failures_only_enabled; then + if [ "$fn_name" = "$interpolated_fn_name" ]; then + bashunit::console_results::print_successful_test "${label}" "$duration" "$@" + else + bashunit::console_results::print_successful_test "${label}" "$duration" + fi + fi + _BASHUNIT_RETRY_NOTE="" + bashunit::state::add_tests_passed + bashunit::reports::add_test_passed "$test_file" "$label" "$duration" "$total_assertions" + bashunit::internal_log "Test passed" "$label" +} diff --git a/src/runner/hooks.sh b/src/runner/hooks.sh new file mode 100644 index 00000000..d72e3f15 --- /dev/null +++ b/src/runner/hooks.sh @@ -0,0 +1,380 @@ +#!/usr/bin/env bash + +function bashunit::runner::cleanup_on_exit() { + local test_file="$1" + local exit_code="$2" + + # Disable coverage trap before cleanup to avoid interference + if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then + bashunit::coverage::disable_trap + fi + + set +e + + # Settle a bashunit::assert_once marker the test body left open, before + # tear_down runs its own assertions and before the counters are exported. + bashunit::assert::once_flush + + # Detect unexpected subshell exit during set_up (Issue #611). + # When 'source' of a non-existent file fails under set -eE, the ERR trap + # does not fire. On macOS Bash 3.2, $? is 0 in the EXIT trap; on Linux + # Bash 5.x, $? is 1. In both cases the hook failure is not recorded. + # Additionally, the stdout redirect from execute_test_hook leaks into the + # EXIT trap. Restore stdout from saved FD 5 so export_subshell_context + # output reaches test_execution_result. + # shellcheck disable=SC2031 + if [ "${_BASHUNIT_SETUP_COMPLETED:-true}" != "true" ]; then + exec 1>&5 + if [ "$exit_code" -eq 0 ]; then + exit_code=1 + fi + if [ -z "${_BASHUNIT_TEST_HOOK_FAILURE:-}" ]; then + bashunit::state::set_test_hook_failure "set_up" + bashunit::state::set_test_hook_message "Hook 'set_up' failed unexpectedly (e.g., source of non-existent file)" + fi + fi + + # Don't use || here - it disables ERR trap in the entire call chain + bashunit::runner::run_tear_down "$test_file" + local teardown_status=$? + bashunit::runner::clear_mocks + bashunit::cleanup_testcase_temp_files + + if [ $teardown_status -ne 0 ]; then + bashunit::state::set_test_exit_code "$teardown_status" + else + bashunit::state::set_test_exit_code "$exit_code" + fi + + bashunit::state::export_subshell_context +} + +function bashunit::runner::record_file_hook_failure() { + local hook_name="$1" + local test_file="$2" + local hook_output="$3" + local status="$4" + local render_header="${5:-false}" + + if [ "$render_header" = true ]; then + bashunit::runner::render_running_file_header "$test_file" true + fi + + if [ -z "$hook_output" ]; then + hook_output="Hook '$hook_name' failed with exit code $status" + fi + + bashunit::state::add_tests_failed + bashunit::console_results::print_error_test "$hook_name" "$hook_output" + local _normalized_hook + _normalized_hook="$(bashunit::helper::normalize_test_function_name "$hook_name")" + bashunit::reports::add_test_failed "$test_file" "$_normalized_hook" 0 0 "$hook_output" + bashunit::runner::write_failure_result_output "$test_file" "$hook_name" "$hook_output" + + return "$status" +} + +function bashunit::runner::execute_file_hook() { + local hook_name="$1" + local test_file="$2" + local render_header="${3:-false}" + + declare -F "$hook_name" >/dev/null 2>&1 || return 0 + + local hook_output="" + local status=0 + local hook_output_file + hook_output_file=$(bashunit::temp_file "${hook_name}_output") + + # Enable errtrace to catch any failing command in the hook. + # Using -E (errtrace) without -e (errexit) prevents the main process from + # exiting on source failures (Bash 3.2 doesn't trigger ERR trap with -eE). + # The ERR trap saves the exit status to a global variable, cleans up shell + # options, and returns from the hook function to prevent subsequent commands + # from executing. + # Variables set before the failure are preserved since we don't use a subshell. + _BASHUNIT_HOOK_ERR_STATUS=0 + set -E + if bashunit::env::is_strict_mode_enabled; then + set -uo pipefail + fi + # The trap returns from the function where the failure occurred (early-exit + # semantics for intermediate failing commands) — but only when that frame is + # NOT this executor: on Bash >= 4 the trap also fires HERE when the hook call + # itself returns non-zero, and an unconditional return skipped + # record_file_hook_failure entirely (silent failures, off-by-one counts, #836). + # shellcheck disable=SC2154 + trap '_BASHUNIT_HOOK_ERR_STATUS=$? + if [ "${FUNCNAME[0]:-}" != "bashunit::runner::execute_file_hook" ]; then + set +Eu +o pipefail + trap - ERR + return $_BASHUNIT_HOOK_ERR_STATUS + fi' ERR + + { + "$hook_name" + } >"$hook_output_file" 2>&1 + # Real exit status of the hook, read from $? (this function runs without -e, + # so a failing compound does not exit). The ERR-trap global alone is not + # enough: a hook ending in a failing `cmd && var=x` guard returns non-zero + # without ever firing the trap (&& lists are ERR-exempt), which silently + # swallowed the failure (#836). + status=$? + if [ "$status" -eq 0 ]; then + status=$_BASHUNIT_HOOK_ERR_STATUS + fi + + trap - ERR + set +Eu +o pipefail + + if [ -f "$hook_output_file" ]; then + hook_output="" + local line + while IFS= read -r line; do + [ -z "$hook_output" ] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" + done <"$hook_output_file" + rm -f "$hook_output_file" + fi + + if [ $status -ne 0 ]; then + bashunit::runner::record_file_hook_failure "$hook_name" "$test_file" "$hook_output" "$status" "$render_header" + return $status + fi + + if [ -n "$hook_output" ] && bashunit::env::is_verbose_enabled; then + printf "%s\n" "$hook_output" + fi + + return 0 +} + +function bashunit::runner::run_set_up() { + local _test_file="${1-}" + bashunit::internal_log "run_set_up" + bashunit::runner::execute_test_hook 'set_up' +} + +function bashunit::runner::run_set_up_before_script() { + local test_file="$1" + bashunit::internal_log "run_set_up_before_script" + + # Check if hook exists first + if ! declare -F "set_up_before_script" >/dev/null 2>&1; then + return 0 + fi + + local start_time + start_time=$(bashunit::clock::now) + + # Enable coverage trap to attribute lines executed during set_up_before_script + if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then + bashunit::coverage::enable_trap + fi + + # Execute the hook (render_header=false since header is already rendered) + bashunit::runner::execute_file_hook 'set_up_before_script' "$test_file" false + local status=$? + + # Disable coverage trap after hook execution + if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then + bashunit::coverage::disable_trap + fi + + local end_time + end_time=$(bashunit::clock::now) + local duration_ns=$((end_time - start_time)) + local duration_ms=$((duration_ns / 1000000)) + + # Print completion message only if hook succeeded + if [ $status -eq 0 ]; then + bashunit::console_results::print_hook_completed "set_up_before_script" "$duration_ms" + fi + + return $status +} + +function bashunit::runner::run_tear_down() { + local _test_file="${1-}" + bashunit::internal_log "run_tear_down" + bashunit::runner::execute_test_hook 'tear_down' +} + +function bashunit::runner::execute_test_hook() { + local hook_name="$1" + + declare -F "$hook_name" >/dev/null 2>&1 || return 0 + + local hook_output="" + local status=0 + local hook_output_file + hook_output_file=$(bashunit::temp_file "${hook_name}_output") + + # Enable errtrace to catch any failing command in the hook. + # Using -E (errtrace) without -e (errexit) prevents the subshell from + # exiting on source failures (Bash 3.2 doesn't trigger ERR trap with -eE). + # The ERR trap saves the exit status to a global variable, cleans up shell + # options, and returns from the hook function to prevent subsequent commands + # from executing. + # Variables set before the failure are preserved since we don't use a subshell. + _BASHUNIT_HOOK_ERR_STATUS=0 + set -E + if bashunit::env::is_strict_mode_enabled; then + set -uo pipefail + fi + # See the twin comment in execute_file_hook: conditional return keeps the + # early-exit semantics for intermediate failures without silently returning + # from THIS executor when the trap re-fires here on Bash >= 4 (#836). + # shellcheck disable=SC2154 + trap '_BASHUNIT_HOOK_ERR_STATUS=$? + if [ "${FUNCNAME[0]:-}" != "bashunit::runner::execute_test_hook" ]; then + set +Eu +o pipefail + trap - ERR + return $_BASHUNIT_HOOK_ERR_STATUS + fi' ERR + + { + "$hook_name" + } >"$hook_output_file" 2>&1 + # Real hook status from $?; the trap global alone misses failing + # `cmd && var=x` guards (&& lists are ERR-exempt) (#836). + status=$? + if [ "$status" -eq 0 ]; then + status=$_BASHUNIT_HOOK_ERR_STATUS + fi + + trap - ERR + set +Eu +o pipefail + + if [ -f "$hook_output_file" ]; then + hook_output="" + local line + while IFS= read -r line; do + [ -z "$hook_output" ] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" + done <"$hook_output_file" + rm -f "$hook_output_file" + fi + + if [ $status -ne 0 ]; then + local message="$hook_output" + if [ -n "$hook_output" ]; then + printf "%s" "$hook_output" + else + message="Hook '$hook_name' failed with exit code $status" + printf "%s\n" "$message" >&2 + fi + bashunit::runner::record_test_hook_failure "$hook_name" "$message" "$status" + return "$status" + fi + + if [ -n "$hook_output" ]; then + printf "%s" "$hook_output" + fi + + return 0 +} + +function bashunit::runner::record_test_hook_failure() { + local hook_name="$1" + local hook_message="$2" + local status="$3" + + if [ -n "$_BASHUNIT_TEST_HOOK_FAILURE" ]; then + return "$status" + fi + + bashunit::state::set_test_hook_failure "$hook_name" + bashunit::state::set_test_hook_message "$hook_message" + + return "$status" +} + +function bashunit::runner::clear_mocks() { + if [ "${#_BASHUNIT_MOCKED_FUNCTIONS[@]}" -eq 0 ]; then + return + fi + + local i + for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do + bashunit::unmock "${_BASHUNIT_MOCKED_FUNCTIONS[$i]:-}" + done +} + +function bashunit::runner::run_tear_down_after_script() { + local test_file="$1" + bashunit::internal_log "run_tear_down_after_script" + + # Check if hook exists first + if ! declare -F "tear_down_after_script" >/dev/null 2>&1; then + # Add blank line after tests if no tear_down hook + if ! bashunit::env::is_simple_output_enabled && + ! bashunit::env::is_failures_only_enabled && + ! bashunit::env::is_no_progress_enabled && + ! bashunit::parallel::is_enabled; then + echo "" + fi + return 0 + fi + + local start_time + start_time=$(bashunit::clock::now) + + # Enable coverage trap to attribute lines executed during tear_down_after_script + if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then + bashunit::coverage::enable_trap + fi + + # Execute the hook + bashunit::runner::execute_file_hook 'tear_down_after_script' "$test_file" + local status=$? + + # Disable coverage trap after hook execution + if [ "${_BASHUNIT_COVERAGE_ON:-0}" = 1 ]; then + bashunit::coverage::disable_trap + fi + + local end_time + end_time=$(bashunit::clock::now) + local duration_ns=$((end_time - start_time)) + local duration_ms=$((duration_ns / 1000000)) + + # Print completion message only if hook succeeded + if [ $status -eq 0 ]; then + bashunit::console_results::print_hook_completed "tear_down_after_script" "$duration_ms" + fi + + # Add blank line after tear_down output + if ! bashunit::env::is_simple_output_enabled && + ! bashunit::env::is_failures_only_enabled && + ! bashunit::env::is_no_progress_enabled && + ! bashunit::parallel::is_enabled; then + echo "" + fi + + return $status +} + +## +# Unset a file's test functions once the file has been processed. +# +# Test files are sourced into the main shell, and their functions used to stay +# defined for the whole run: every test's $() subshell then forked an +# ever-growing shell, making multi-file runs quadratic in file count (#829). +# In parallel mode the file's workers have already forked (with their own copy +# of the functions) by the time this runs, so unsetting here is race-free. +# Arguments: $1 - whitespace-separated test function names +## +function bashunit::runner::clean_script_test_functions() { + local IFS=$' \t\n' + local fn + for fn in $1; do + unset -f "$fn" 2>/dev/null || true + done +} + +function bashunit::runner::clean_set_up_and_tear_down_after_script() { + bashunit::internal_log "clean_set_up_and_tear_down_after_script" + bashunit::helper::unset_if_exists 'set_up' + bashunit::helper::unset_if_exists 'tear_down' + bashunit::helper::unset_if_exists 'set_up_before_script' + bashunit::helper::unset_if_exists 'tear_down_after_script' +} diff --git a/src/runner/parallel.sh b/src/runner/parallel.sh new file mode 100644 index 00000000..ab7d325c --- /dev/null +++ b/src/runner/parallel.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +# Returns 0 when this Bash supports `wait -n` (Bash 4.3+), 1 otherwise. +function bashunit::runner::_supports_wait_n() { + local major="${BASH_VERSINFO[0]:-0}" + local minor="${BASH_VERSINFO[1]:-0}" + if [ "$major" -gt 4 ]; then + return 0 + fi + if [ "$major" -eq 4 ] && [ "$minor" -ge 3 ]; then + return 0 + fi + return 1 +} + +_BASHUNIT_RUNNER_RUNNING_JOBS_OUT=0 + +# Counts running background jobs into _BASHUNIT_RUNNER_RUNNING_JOBS_OUT. `jobs -pr` +# still needs one command substitution, but the line count is pure-bash, so this +# drops the extra `wc` fork per poll iteration on the parallel hot path (#761). +function bashunit::runner::_count_running_jobs() { + local running + running=$(jobs -pr) + if [ -z "$running" ]; then + _BASHUNIT_RUNNER_RUNNING_JOBS_OUT=0 + return + fi + local newlines="${running//[!$'\n']/}" + _BASHUNIT_RUNNER_RUNNING_JOBS_OUT=$((${#newlines} + 1)) +} + +function bashunit::runner::wait_for_job_slot() { + local max_jobs="${BASHUNIT_PARALLEL_JOBS:-0}" + if [ "$max_jobs" -le 0 ]; then + return 0 + fi + + if bashunit::runner::_supports_wait_n; then + # Bash 4.3+: block until any child exits. No polling, no sleep latency. + bashunit::runner::_count_running_jobs + while [ "$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT" -ge "$max_jobs" ]; do + wait -n 2>/dev/null || break + bashunit::runner::_count_running_jobs + done + return 0 + fi + + # Bash 3.x fallback: adaptive poll starting at 50ms, growing to 200ms to + # reduce `jobs -r` overhead on long-running tests while staying responsive. + local delay="0.05" + local iterations=0 + while true; do + bashunit::runner::_count_running_jobs + if [ "$_BASHUNIT_RUNNER_RUNNING_JOBS_OUT" -lt "$max_jobs" ]; then + break + fi + sleep "$delay" + iterations=$((iterations + 1)) + if [ "$iterations" -eq 4 ]; then + delay="0.1" + elif [ "$iterations" -eq 20 ]; then + delay="0.2" + fi + done +} + +function bashunit::runner::spinner() { + # Only show spinner when output is to a terminal + if [ ! -t 1 ]; then + # Not a terminal, just wait silently + while true; do sleep 1; done + return + fi + + # Don't show spinner in no-progress mode + if bashunit::env::is_no_progress_enabled; then + while true; do sleep 1; done + return + fi + + if bashunit::env::is_simple_output_enabled; then + printf "\n" + fi + + local delay=0.1 + local spin_chars="|/-\\" + while true; do + local i + for ((i = 0; i < ${#spin_chars}; i++)); do + printf "\r%s" "${spin_chars:$i:1}" + sleep "$delay" + done + done +} diff --git a/src/runner/payload.sh b/src/runner/payload.sh new file mode 100644 index 00000000..2cc6eed8 --- /dev/null +++ b/src/runner/payload.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash + +# Hot-path result helpers below return their value via a dedicated global slot +# (`_BASHUNIT_RUNNER_*_OUT`) instead of stdout. This avoids the per-test +# `$(...)` subshell capture that dominated the result-parsing hot path. Callers +# invoke the helper and immediately read the slot: +# +# bashunit::runner::extract_subshell_type "$subshell_output" +# type=$_BASHUNIT_RUNNER_TYPE_OUT +# +# A dedicated slot per helper (rather than one shared slot) means nested or +# adjacent calls cannot clobber each other and callers don't need to copy out +# before every other helper runs. +_BASHUNIT_RUNNER_FIELD_OUT="" +_BASHUNIT_RUNNER_TOTAL_OUT="" +_BASHUNIT_RUNNER_TYPE_OUT="" +_BASHUNIT_RUNNER_OUTPUT_OUT="" +_BASHUNIT_RUNNER_INTERP_OUT="" +_BASHUNIT_RUNNER_COUNTS_FAILED_OUT=0 +_BASHUNIT_RUNNER_COUNTS_PASSED_OUT=0 +_BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT=0 +_BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT=0 +_BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT=0 +_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT=0 +_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" +_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="" +# Per-suite ordinal for naming a parallel worker's `.result` file. Set by +# call_test_functions (single-threaded dispatch) just before each backgrounded +# run_test; the fork inherits the value, so every test in a file gets a unique, +# collision-free name in its per-suite dir without forking mktemp/mv. +_BASHUNIT_RUNNER_RESULT_ORDINAL=0 +# Suffix appended to a passed-test line when it only passed after retrying. +_BASHUNIT_RETRY_NOTE="" + +# Writes the value of an encoded field (##KEY=value##) into _BASHUNIT_RUNNER_FIELD_OUT. +# Arguments: $1 test_execution_result, $2 key +function bashunit::runner::extract_encoded_field() { + local test_execution_result=$1 + local key=$2 + local marker="##${key}=" + case "$test_execution_result" in + *"$marker"*) + local rest="${test_execution_result#*"$marker"}" + _BASHUNIT_RUNNER_FIELD_OUT="${rest%%##*}" + ;; + *) _BASHUNIT_RUNNER_FIELD_OUT="" ;; + esac +} + +# Writes the sum of all ASSERTIONS_* counters into _BASHUNIT_RUNNER_TOTAL_OUT. +# Arguments: $1 test_execution_result +function bashunit::runner::compute_total_assertions() { + local test_execution_result=$1 + local failed passed skipped incomplete snapshot + failed="${test_execution_result##*##ASSERTIONS_FAILED=}" + failed="${failed%%##*}" + passed="${test_execution_result##*##ASSERTIONS_PASSED=}" + passed="${passed%%##*}" + skipped="${test_execution_result##*##ASSERTIONS_SKIPPED=}" + skipped="${skipped%%##*}" + incomplete="${test_execution_result##*##ASSERTIONS_INCOMPLETE=}" + incomplete="${incomplete%%##*}" + snapshot="${test_execution_result##*##ASSERTIONS_SNAPSHOT=}" + snapshot="${snapshot%%##*}" + # A result that never reached the payload (a SIGKILLed subshell, raw stderr) + # leaves every ##KEY= strip a no-op, so these fields hold arbitrary text. That + # text is not "0" to `$(( ))`: it is a fatal arithmetic syntax error that + # aborts the run. One `case` over the concatenation costs no fork and keeps + # the happy path (all digits, or empty for an absent counter) untouched. + case "$failed$passed$skipped$incomplete$snapshot" in + *[!0-9]*) failed=0 passed=0 skipped=0 incomplete=0 snapshot=0 ;; + esac + local total + total=$((failed + passed + skipped)) + total=$((total + incomplete + snapshot)) + _BASHUNIT_RUNNER_TOTAL_OUT=$total +} + +# Writes the subshell type marker (text inside leading [...]) into _BASHUNIT_RUNNER_TYPE_OUT. +# Arguments: $1 subshell_output +function bashunit::runner::extract_subshell_type() { + local subshell_output=$1 + local type="${subshell_output%%]*}" + _BASHUNIT_RUNNER_TYPE_OUT="${type#[}" +} + +# Writes the subshell output (minus the leading [type] marker, with embedded +# status markers replaced by newlines) into _BASHUNIT_RUNNER_OUTPUT_OUT. +# Arguments: $1 subshell_output +function bashunit::runner::format_subshell_output() { + local subshell_output=$1 + local line="${subshell_output#*]}" + line=${line//\[failed\]/$'\n'} + line=${line//\[skipped\]/$'\n'} + line=${line//\[incomplete\]/$'\n'} + _BASHUNIT_RUNNER_OUTPUT_OUT=$line +} + +# Writes the decoded subshell output into _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT. +# The empty case (a passing test with no captured output) short-circuits with +# no subshell at all; only the non-empty path pays the base64 fork (#762/#764). +# Arguments: $1 test_execution_result +function bashunit::runner::decode_subshell_output() { + local test_execution_result="$1" + + local test_output_base64="${test_execution_result##*##TEST_OUTPUT=}" + test_output_base64="${test_output_base64%%##*}" + if [ -z "$test_output_base64" ] || [ "$test_output_base64" = "$_BASHUNIT_BASE64_EMPTY_SENTINEL" ]; then + _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="" + return + fi + _BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT="$(bashunit::helper::decode_base64 "$test_output_base64")" +} + +function bashunit::runner::is_simple_progress_output() { + local output="$1" + + [ -n "$output" ] || return 1 + + local color + for color in \ + "$_BASHUNIT_COLOR_DEFAULT" \ + "$_BASHUNIT_COLOR_PASSED" \ + "$_BASHUNIT_COLOR_FAILED" \ + "$_BASHUNIT_COLOR_SKIPPED" \ + "$_BASHUNIT_COLOR_INCOMPLETE" \ + "$_BASHUNIT_COLOR_SNAPSHOT" \ + "$_BASHUNIT_COLOR_RISKY"; do + [ -n "$color" ] && output="${output//"$color"/}" + done + + local i + local char + for ((i = 0; i < ${#output}; i++)); do + char="${output:$i:1}" + case "$char" in + "." | "F" | "S" | "I" | "N" | "R" | "E" | "?") ;; + *) return 1 ;; + esac + done + + return 0 +} + +function bashunit::runner::line_exists_in_output() { + local needle="$1" + local haystack="$2" + local line + + while IFS= read -r line || [ -n "$line" ]; do + [ "$line" = "$needle" ] && return 0 + done <<<"$haystack" + + return 1 +} + +function bashunit::runner::extract_assertion_runtime_output() { + local runtime_output="$1" + local rendered_assertion_output="$2" + local filtered_output="" + local line + + while IFS= read -r line || [ -n "$line" ]; do + if bashunit::runner::line_exists_in_output "$line" "$rendered_assertion_output"; then + continue + fi + if bashunit::runner::is_simple_progress_output "$line"; then + continue + fi + + [ -n "$filtered_output" ] && filtered_output="$filtered_output"$'\n' + filtered_output="$filtered_output$line" + done <<<"$runtime_output" + + runtime_output="$filtered_output" + + while [ -n "$runtime_output" ]; do + case "$runtime_output" in + *$'\n') runtime_output="${runtime_output%$'\n'}" ;; + *) break ;; + esac + done + + echo "$runtime_output" +} + +# shellcheck disable=SC2295 +## +# Parses the encoded per-test result's last line into the counts out-slots +# (_BASHUNIT_RUNNER_COUNTS_*_OUT). Pure read: never mutates the cumulative +# _BASHUNIT_ASSERTIONS_* / _BASHUNIT_TEST_EXIT_CODE state, so the retry loop can +# judge an attempt's outcome without committing it. +## +function bashunit::runner::extract_result_counts() { + local execution_result=$1 + + local result_line + result_line="${execution_result##*$'\n'}" + + local assertions_failed=0 + local assertions_passed=0 + local assertions_skipped=0 + local assertions_incomplete=0 + local assertions_snapshot=0 + local test_exit_code=0 + + # Extract values using parameter expansion instead of spawning grep/sed subprocesses + case "$result_line" in + *"ASSERTIONS_FAILED="*"##ASSERTIONS_PASSED="*) + local _tail + _tail="${result_line##*ASSERTIONS_FAILED=}" + assertions_failed="${_tail%%##*}" + _tail="${result_line##*ASSERTIONS_PASSED=}" + assertions_passed="${_tail%%##*}" + _tail="${result_line##*ASSERTIONS_SKIPPED=}" + assertions_skipped="${_tail%%##*}" + _tail="${result_line##*ASSERTIONS_INCOMPLETE=}" + assertions_incomplete="${_tail%%##*}" + _tail="${result_line##*ASSERTIONS_SNAPSHOT=}" + assertions_snapshot="${_tail%%##*}" + _tail="${result_line##*TEST_EXIT_CODE=}" + test_exit_code="${_tail%%##*}" + # Strip any trailing non-digit suffix (end of line) from the final field + test_exit_code="${test_exit_code%%[!0-9]*}" + : "${assertions_failed:=0}" + : "${assertions_passed:=0}" + : "${assertions_skipped:=0}" + : "${assertions_incomplete:=0}" + : "${assertions_snapshot:=0}" + : "${test_exit_code:=0}" + ;; + esac + + _BASHUNIT_RUNNER_COUNTS_FAILED_OUT=$assertions_failed + _BASHUNIT_RUNNER_COUNTS_PASSED_OUT=$assertions_passed + _BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT=$assertions_skipped + _BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT=$assertions_incomplete + _BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT=$assertions_snapshot + _BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT=$test_exit_code +} diff --git a/src/runner/provider.sh b/src/runner/provider.sh new file mode 100644 index 00000000..3b876054 --- /dev/null +++ b/src/runner/provider.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash + +function bashunit::runner::parse_data_provider_args() { + local input="$1" + local current_arg="" + local in_quotes=false + local had_quotes=false # Track if arg was quoted (to preserve empty quoted strings) + local quote_char="" + local escaped=false + local IFS=$' \t\n' + local i=0 + local arg="" + local encoded_arg + local -a args=() + local args_count=0 + + # Check for unescaped shell metacharacters that would break eval or cause + # globbing. Combines the leading-metachar case and the embedded-metachar + # case into a single regex to avoid a second grep subprocess per call. + local has_metachar=false + if [ "$(echo "$input" | "$GREP" -cE '(^|[^\])[|&;*]' || true)" -gt 0 ]; then + has_metachar=true + fi + + # Try eval first (needed for $'...' from printf '%q'), unless metacharacters present + if [ "$has_metachar" = false ] && eval "args=($input)" 2>/dev/null; then + # Check if args has elements after eval + args_count=0 + local _tmp arg + for _tmp in ${args+"${args[@]}"}; do args_count=$((args_count + 1)); done + if [ "$args_count" -gt 0 ]; then + # Successfully parsed - remove sentinel if present + local last_idx=$((args_count - 1)) + if [ -z "${args[$last_idx]}" ]; then + unset 'args[$last_idx]' + fi + # Print args and return early + for arg in "${args[@]+"${args[@]}"}"; do + encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" + printf '%s\n' "$encoded_arg" + done + return + fi + fi + + # Fallback: parse args from the input string into an array, respecting quotes and escapes + local i + for ((i = 0; i < ${#input}; i++)); do + local char="${input:$i:1}" + if [ "$escaped" = true ]; then + case "$char" in + t) current_arg="$current_arg"$'\t' ;; + n) current_arg="$current_arg"$'\n' ;; + *) current_arg="$current_arg$char" ;; + esac + escaped=false + elif [ "$char" = "\\" ]; then + escaped=true + elif [ "$in_quotes" = false ]; then + case "$char" in + "$") + # Handle $'...' syntax + if [ "${input:$i:2}" = "$'" ]; then + in_quotes=true + had_quotes=true + quote_char="'" + # Skip the $ + i=$((i + 1)) + else + current_arg="$current_arg$char" + fi + ;; + "'" | '"') + in_quotes=true + had_quotes=true + quote_char="$char" + ;; + " " | $'\t') + # Add if non-empty OR if was quoted (to preserve empty quoted strings like '') + if [ -n "$current_arg" ] || [ "$had_quotes" = true ]; then + args[args_count]="$current_arg" + args_count=$((args_count + 1)) + fi + current_arg="" + had_quotes=false + ;; + *) + current_arg="$current_arg$char" + ;; + esac + elif [ "$char" = "$quote_char" ]; then + in_quotes=false + quote_char="" + else + current_arg="$current_arg$char" + fi + done + args[args_count]="$current_arg" + args_count=$((args_count + 1)) + # Remove all trailing empty strings + while [ "$args_count" -gt 0 ]; do + local last_idx=$((args_count - 1)) + if [ -z "${args[$last_idx]}" ]; then + unset 'args[$last_idx]' + args_count=$((args_count - 1)) + else + break + fi + done + # Print one arg per line to stdout, base64-encoded to preserve newlines in the data + local arg + for arg in ${args+"${args[@]}"}; do + encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" + printf '%s\n' "$encoded_arg" + done +} diff --git a/src/runner/result.sh b/src/runner/result.sh new file mode 100644 index 00000000..cac00c0b --- /dev/null +++ b/src/runner/result.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash + +function bashunit::runner::parse_result() { + local fn_name=$1 + shift + local execution_result=$1 + shift + local IFS=$' \t\n' + local -a args + args=("$@") + + if bashunit::parallel::is_enabled; then + bashunit::runner::parse_result_parallel "$fn_name" "$execution_result" ${args+"${args[@]}"} + else + bashunit::runner::parse_result_sync "$fn_name" "$execution_result" + fi +} + +function bashunit::runner::parse_result_parallel() { + local fn_name=$1 + shift + local execution_result=$1 + # This runs once per test in every parallel worker, so avoid per-test forks: + # derive the suite dir name with parameter expansion (no basename), only + # mkdir when the dir is missing (first test of the file wins the race, + # `-p` makes the losers no-ops), and name the result file by the per-suite + # ordinal the dispatcher assigned — unique without forking mktemp or mv. + local test_suite_base="${test_file##*/}" + local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/${test_suite_base%.sh}" + [ -d "$test_suite_dir" ] || mkdir -p "$test_suite_dir" + + local unique_test_result_file="${test_suite_dir}/${_BASHUNIT_RUNNER_RESULT_ORDINAL}.result" + + bashunit::internal_log "[PARA]" "fn_name:$fn_name" "execution_result:$execution_result" + + bashunit::runner::parse_result_sync "$fn_name" "$execution_result" + + echo "$execution_result" >"$unique_test_result_file" +} + +function bashunit::runner::parse_result_sync() { + local fn_name=$1 + local execution_result=$2 + + bashunit::runner::extract_result_counts "$execution_result" + + bashunit::internal_log "[SYNC]" "fn_name:$fn_name" "execution_result:$execution_result" + + _BASHUNIT_ASSERTIONS_PASSED=$((_BASHUNIT_ASSERTIONS_PASSED + _BASHUNIT_RUNNER_COUNTS_PASSED_OUT)) + _BASHUNIT_ASSERTIONS_FAILED=$((_BASHUNIT_ASSERTIONS_FAILED + _BASHUNIT_RUNNER_COUNTS_FAILED_OUT)) + _BASHUNIT_ASSERTIONS_SKIPPED=$((_BASHUNIT_ASSERTIONS_SKIPPED + _BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT)) + _BASHUNIT_ASSERTIONS_INCOMPLETE=$((_BASHUNIT_ASSERTIONS_INCOMPLETE + _BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT)) + _BASHUNIT_ASSERTIONS_SNAPSHOT=$((_BASHUNIT_ASSERTIONS_SNAPSHOT + _BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT)) + _BASHUNIT_TEST_EXIT_CODE=$((_BASHUNIT_TEST_EXIT_CODE + _BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT)) + + bashunit::internal_log "result_summary" \ + "failed:$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" \ + "passed:$_BASHUNIT_RUNNER_COUNTS_PASSED_OUT" \ + "skipped:$_BASHUNIT_RUNNER_COUNTS_SKIPPED_OUT" \ + "incomplete:$_BASHUNIT_RUNNER_COUNTS_INCOMPLETE_OUT" \ + "snapshot:$_BASHUNIT_RUNNER_COUNTS_SNAPSHOT_OUT" \ + "exit_code:$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" +} + +function bashunit::runner::write_failure_result_output() { + local test_file=$1 + local fn_name=$2 + local error_msg=$3 + local raw_output="${4:-}" + + local line_number + line_number=$(bashunit::helper::get_function_line_number "$fn_name") + + local test_nr="*" + if ! bashunit::parallel::is_enabled; then + test_nr=$(bashunit::state::get_tests_failed) + fi + + local output_section="" + if [ -n "$raw_output" ] && bashunit::env::is_show_output_on_failure_enabled; then + output_section="\n Output:\n$raw_output" + fi + + local source_context="" + if [ -n "$line_number" ] && [ -f "$test_file" ]; then + source_context=$(bashunit::runner::get_failure_source_context \ + "$test_file" "$line_number") + fi + + echo -e "$test_nr) $test_file:$line_number\n$error_msg$output_section$source_context" \ + >>"$FAILURES_OUTPUT_PATH" +} + +function bashunit::runner::get_failure_source_context() { + local file=$1 + local fn_line=$2 + + # Read the file once (a bash builtin loop) instead of forking `sed` to fetch + # each line and `grep` to test each line for the closing brace. The fork count + # no longer grows with the function length. + local line_text line_num=0 assert_lines="" stripped trimmed + while IFS= read -r line_text || [ -n "$line_text" ]; do + line_num=$((line_num + 1)) + # Skip everything up to and including the function definition line. + if [ "$line_num" -le "$fn_line" ]; then + continue + fi + # Stop at the closing brace of the function (a line that is only `}`). + stripped="${line_text#"${line_text%%[![:space:]]*}"}" + stripped="${stripped%"${stripped##*[![:space:]]}"}" + if [ "$stripped" = "}" ]; then + break + fi + # Collect lines containing assert calls + case "$line_text" in + *assert_* | *assert\ *) + trimmed="${line_text#"${line_text%%[![:space:]]*}"}" + assert_lines="${assert_lines}\n ${_BASHUNIT_COLOR_FAINT}${line_num}:${_BASHUNIT_COLOR_DEFAULT} ${trimmed}" + ;; + esac + done <"$file" + + if [ -n "$assert_lines" ]; then + echo -e "\n ${_BASHUNIT_COLOR_FAINT}Source:${_BASHUNIT_COLOR_DEFAULT}${assert_lines}" + fi +} + +function bashunit::runner::write_skipped_result_output() { + local test_file=$1 + local fn_name=$2 + local output_msg=$3 + + local line_number + line_number=$(bashunit::helper::get_function_line_number "$fn_name") + + local test_nr="*" + if ! bashunit::parallel::is_enabled; then + test_nr=$(bashunit::state::get_tests_skipped) + fi + + echo -e "$test_nr) $test_file:$line_number\n$output_msg" >>"$SKIPPED_OUTPUT_PATH" +} + +function bashunit::runner::write_incomplete_result_output() { + local test_file=$1 + local fn_name=$2 + local output_msg=$3 + + local line_number + line_number=$(bashunit::helper::get_function_line_number "$fn_name") + + local test_nr="*" + if ! bashunit::parallel::is_enabled; then + test_nr=$(bashunit::state::get_tests_incomplete) + fi + + echo -e "$test_nr) $test_file:$line_number\n$output_msg" >>"$INCOMPLETE_OUTPUT_PATH" +} + +function bashunit::runner::write_risky_result_output() { + local test_file=$1 + local fn_name=$2 + + local line_number + line_number=$(bashunit::helper::get_function_line_number "$fn_name") + + local test_nr="*" + if ! bashunit::parallel::is_enabled; then + test_nr=$(bashunit::state::get_tests_risky) + fi + + echo -e "$test_nr) $test_file:$line_number\nTest has no assertions (risky)" >>"$RISKY_OUTPUT_PATH" +} diff --git a/src/state.sh b/src/state.sh index 72426c40..0296275f 100644 --- a/src/state.sh +++ b/src/state.sh @@ -13,8 +13,8 @@ unset _bashunit_base64_help # Wire sentinel for an empty base64 payload. base64 of "" is "", which gets lost # in line parsing, so encode_base64 emits this token and both decode sites map it # back to "". Single source of truth keeps the encode (helpers.sh) and decode -# (helpers.sh, runner.sh) sides byte-identical. -# shellcheck disable=SC2034 # read cross-file in helpers.sh and runner.sh +# (helpers.sh, runner/payload.sh) sides byte-identical. +# shellcheck disable=SC2034 # read cross-file in helpers.sh and runner/payload.sh _BASHUNIT_BASE64_EMPTY_SENTINEL="_BASHUNIT_EMPTY_" _BASHUNIT_TESTS_PASSED=0 diff --git a/tests/unit/build_test.sh b/tests/unit/build_test.sh index e7a8f2b4..b593098c 100644 --- a/tests/unit/build_test.sh +++ b/tests/unit/build_test.sh @@ -89,6 +89,24 @@ function test_build_embed_docs_fails_on_missing_markers() { assert_contains "echo hi" "$(cat "$file")" } +# build::process_file emits a file's body and *then* recurses into its `source` +# lines, so an aggregator holding anything else at top level would run that code +# before its dependencies in the built binary but after them in dev mode. Add any +# new aggregator here. +function test_module_aggregators_hold_only_source_lines_and_comments() { + local aggregators="src/assertions.sh src/runner.sh" + + local offenders="" + local aggregator + for aggregator in $aggregators; do + if grep -qvE '^[[:space:]]*(#|source |$)' "$ROOT_DIR/$aggregator"; then + offenders="$offenders $aggregator" + fi + done + + assert_empty "$offenders" +} + function test_build_process_file_embeds_a_file_only_once() { local dir dir=$(bashunit::temp_dir) diff --git a/tests/unit/runner_context_test.sh b/tests/unit/runner_context_test.sh new file mode 100644 index 00000000..1654ee24 --- /dev/null +++ b/tests/unit/runner_context_test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +function test_sync_coverage_flag_sets_one_when_enabled() { + local _orig="${BASHUNIT_COVERAGE-}" + BASHUNIT_COVERAGE="true" + bashunit::runner::sync_coverage_flag + assert_same "1" "$_BASHUNIT_COVERAGE_ON" + BASHUNIT_COVERAGE="$_orig" + bashunit::runner::sync_coverage_flag +} + +function test_sync_coverage_flag_sets_zero_when_disabled() { + local _orig="${BASHUNIT_COVERAGE-}" + BASHUNIT_COVERAGE="false" + bashunit::runner::sync_coverage_flag + assert_same "0" "$_BASHUNIT_COVERAGE_ON" + BASHUNIT_COVERAGE="$_orig" + bashunit::runner::sync_coverage_flag +} + +function test_sync_coverage_flag_sets_zero_when_unset() { + local _orig="${BASHUNIT_COVERAGE-}" + unset BASHUNIT_COVERAGE + bashunit::runner::sync_coverage_flag + assert_same "0" "$_BASHUNIT_COVERAGE_ON" + BASHUNIT_COVERAGE="$_orig" + bashunit::runner::sync_coverage_flag +} + +function test_supports_reliable_pipefail_matches_bash_version() { + # Reliable on Bash >= 3.1; Bash 3.0 ships a broken pipefail. + local expected_rc=0 + if [ "${BASH_VERSINFO[0]}" -eq 3 ] && [ "${BASH_VERSINFO[1]}" -eq 0 ]; then + expected_rc=1 + fi + + local actual_rc=0 + bashunit::runner::_supports_reliable_pipefail || actual_rc=$? + assert_same "$expected_rc" "$actual_rc" +} + +# --- restore_workdir ---------------------------------------------------------- + +function test_restore_workdir_returns_to_the_given_directory() { + local target + target="$(bashunit::temp_dir)" + + local landed + landed=$( + cd / || exit 1 + bashunit::runner::restore_workdir "$target" + pwd -P + ) + + assert_same "$(cd "$target" && pwd -P)" "$landed" +} + +function test_restore_workdir_aborts_loudly_when_the_directory_is_gone() { + local gone + gone="$(bashunit::temp_dir)/removed" + + local status=0 + local output + # `$(...)` is already a subshell, so the function's `exit 1` ends the capture + # rather than the test. + output="$(bashunit::runner::restore_workdir "$gone" 2>&1)" || status=$? + + assert_same 1 "$status" + assert_contains "cannot restore the working directory" "$output" + assert_contains "$gone" "$output" +} diff --git a/tests/unit/runner_diagnostics_test.sh b/tests/unit/runner_diagnostics_test.sh new file mode 100644 index 00000000..806cccbf --- /dev/null +++ b/tests/unit/runner_diagnostics_test.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +function test_detect_runtime_error_returns_empty_when_input_is_empty() { + bashunit::runner::detect_runtime_error "" + + assert_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" +} + +function test_detect_runtime_error_returns_empty_when_no_known_error() { + bashunit::runner::detect_runtime_error "all good here" + + assert_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" +} + +function test_detect_runtime_error_matches_command_not_found() { + bashunit::runner::detect_runtime_error "script.sh: line 3: foo: command not found" + + assert_same "line 3: foo: command not found" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" +} + +function test_detect_runtime_error_matches_syntax_error() { + bashunit::runner::detect_runtime_error "bash: -c: line 1: syntax error near unexpected token" + + assert_same "-c: line 1: syntax error near unexpected token" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" +} + +function test_detect_runtime_error_matches_killed() { + bashunit::runner::detect_runtime_error "process: killed" + + assert_same "killed" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" +} + +function test_detect_runtime_error_strips_newlines_from_extracted_message() { + local input=$'bash: line 1: foo: command not found\nextra' + bashunit::runner::detect_runtime_error "$input" + + assert_same "line 1: foo: command not foundextra" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" +} + +function test_detect_runtime_error_matches_unexpected_eof() { + bashunit::runner::detect_runtime_error "bash: line 5: unexpected EOF while looking for matching" + + assert_same "line 5: unexpected EOF while looking for matching" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" +} + +function test_classify_kill_signal_sigkill_mentions_oom() { + local output + output="$(bashunit::runner::classify_kill_signal 137)" + + assert_contains "SIGKILL" "$output" + assert_contains "memory" "$output" +} + +function test_classify_kill_signal_sigterm() { + assert_contains "SIGTERM" "$(bashunit::runner::classify_kill_signal 143)" +} + +function test_classify_kill_signal_timeout() { + assert_contains "Timed out" "$(bashunit::runner::classify_kill_signal 124)" +} + +function test_classify_kill_signal_sigint() { + assert_contains "SIGINT" "$(bashunit::runner::classify_kill_signal 130)" +} + +function test_classify_kill_signal_generic_signal() { + assert_contains "signal 6" "$(bashunit::runner::classify_kill_signal 134)" +} + +function test_classify_kill_signal_empty_for_normal_exit() { + assert_empty "$(bashunit::runner::classify_kill_signal 1)" +} diff --git a/tests/unit/runner_discovery_test.sh b/tests/unit/runner_discovery_test.sh new file mode 100644 index 00000000..b2e2f67e --- /dev/null +++ b/tests/unit/runner_discovery_test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +function test_functions_for_script_sorts_by_definition_line() { + local fixture + fixture="$(bashunit::temp_dir)/ffs_order_fixture.sh" + { + echo 'function test_zebra() { :; }' + echo 'function test_alpha() { :; }' + echo 'function test_mid() { :; }' + } >"$fixture" + # shellcheck source=/dev/null + source "$fixture" + + local actual + actual="$(bashunit::runner::functions_for_script "$fixture" "test_alpha test_mid test_zebra")" + + assert_same $'test_zebra\ntest_alpha\ntest_mid' "$actual" +} + +function test_functions_for_script_filters_out_functions_from_other_files() { + local fixture + fixture="$(bashunit::temp_dir)/ffs_filter_fixture.sh" + echo 'function test_only_mine() { :; }' >"$fixture" + # shellcheck source=/dev/null + source "$fixture" + + # test_functions_for_script_filters_out_functions_from_other_files is defined + # in this test file, not in the fixture, so it must be filtered out. + local actual + actual="$(bashunit::runner::functions_for_script "$fixture" \ + "test_only_mine test_functions_for_script_filters_out_functions_from_other_files")" + + assert_same "test_only_mine" "$actual" +} + +function test_functions_for_script_preserves_caller_extdebug_state() { + local fixture + fixture="$(bashunit::temp_dir)/ffs_extdebug_fixture.sh" + echo 'function test_ffs_extdebug() { :; }' >"$fixture" + # shellcheck source=/dev/null + source "$fixture" + + # Toggle inside a subshell so the runner's own shell is never touched. + local state + state=$( + shopt -s extdebug + bashunit::runner::functions_for_script "$fixture" "test_ffs_extdebug" >/dev/null + if shopt -q extdebug; then echo "on"; else echo "off"; fi + ) + + assert_same "on" "$state" +} diff --git a/tests/unit/runner_exec_test.sh b/tests/unit/runner_exec_test.sh new file mode 100644 index 00000000..49fca927 --- /dev/null +++ b/tests/unit/runner_exec_test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +# --- result-line layout contract --------------------------------------------- +# The "##KEY=value##" subshell result record has two writers: +# bashunit::state::export_subshell_context (canonical) and the timeout-path copy +# bashunit::runner::build_timeout_result. They live in different files, so a +# field added to one silently shortens the record the other emits, and every +# reader (runner::extract_result_counts, state::aggregate_parallel_results) +# then mis-parses timed-out tests. Folding the two writers into one function +# would put a call on the per-test hot path that #762/#764 deliberately +# stripped; pinning the layout costs nothing at runtime and fails the moment +# the copies diverge. + +# Echoes the ordered field keys of a "##KEY=value##..." record, one per line. +function result_line_field_keys() { + local rest="$1" + local key + while [ -n "$rest" ]; do + case "$rest" in + *"##"*) rest="${rest#*##}" ;; + *) break ;; + esac + key="${rest%%=*}" + case "$key" in + "$rest") continue ;; + esac + printf '%s\n' "$key" + done +} + +function test_timeout_result_uses_the_same_field_layout_as_the_state_writer() { + local canonical + canonical="$(bashunit::state::export_subshell_context)" + local timeout_line + timeout_line="$(bashunit::runner::build_timeout_result)" + + assert_same \ + "$(result_line_field_keys "$canonical")" \ + "$(result_line_field_keys "$timeout_line")" +} + +function test_timeout_result_reports_the_conventional_timed_out_exit_code() { + bashunit::runner::extract_result_counts "$(bashunit::runner::build_timeout_result)" + + assert_same "124" "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" + assert_same "0" "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" + assert_same "0" "$_BASHUNIT_RUNNER_COUNTS_PASSED_OUT" +} diff --git a/tests/unit/runner_payload_test.sh b/tests/unit/runner_payload_test.sh new file mode 100644 index 00000000..4af0c87e --- /dev/null +++ b/tests/unit/runner_payload_test.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash + +function test_extract_assertion_runtime_output_keeps_user_output() { + local runtime_output + runtime_output=$'diagnostic from stderr\n✗ Failed: Example\n Expected '\''1'\''' + local rendered_assertion_output + rendered_assertion_output=$'✗ Failed: Example\n Expected '\''1'\''' + + local actual + actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" + + assert_same "diagnostic from stderr" "$actual" +} + +function test_extract_assertion_runtime_output_ignores_bashunit_status_output_before_failure() { + local runtime_output + runtime_output=$'✒ Incomplete: Example pending\n✗ Failed: Example\n Expected '\''1'\''' + local rendered_assertion_output + rendered_assertion_output=$'✒ Incomplete: Example pending\n✗ Failed: Example\n Expected '\''1'\''' + + local actual + actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" + + assert_empty "$actual" +} + +function test_extract_assertion_runtime_output_keeps_user_output_after_status_output() { + local runtime_output + runtime_output=$'✓ Passed: Previous assertion\ndiagnostic after pass\n✗ Failed: Example' + local rendered_assertion_output + rendered_assertion_output=$'✓ Passed: Previous assertion\n✗ Failed: Example' + + local actual + actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" + + assert_same "diagnostic after pass" "$actual" +} + +function test_extract_assertion_runtime_output_keeps_user_output_that_looks_like_status_output() { + local runtime_output + runtime_output=$'✗ Failed: emitted by the code under test\n✗ Failed: Example' + local rendered_assertion_output + rendered_assertion_output="✗ Failed: Example" + + local actual + actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" + + assert_same "✗ Failed: emitted by the code under test" "$actual" +} + +function test_decode_subshell_output_writes_empty_for_empty_marker() { + bashunit::runner::decode_subshell_output "pre##TEST_OUTPUT=_BASHUNIT_EMPTY_##ASSERTIONS_PASSED=1" + + assert_empty "$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT" +} + +function test_decode_subshell_output_decodes_non_empty_output_to_slot() { + local encoded + encoded="$(bashunit::helper::encode_base64 "hello output")" + bashunit::runner::decode_subshell_output "pre##TEST_OUTPUT=${encoded}##ASSERTIONS_PASSED=1" + + assert_same "hello output" "$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT" +} + +function test_extract_encoded_field_writes_value_to_slot() { + bashunit::runner::extract_encoded_field \ + "preamble##TEST_TITLE=hello world##ASSERTIONS_PASSED=1" "TEST_TITLE" + + assert_same "hello world" "$_BASHUNIT_RUNNER_FIELD_OUT" +} + +function test_extract_encoded_field_writes_empty_when_key_missing() { + _BASHUNIT_RUNNER_FIELD_OUT="prior" + bashunit::runner::extract_encoded_field "##ASSERTIONS_PASSED=1" "TEST_TITLE" + + assert_empty "$_BASHUNIT_RUNNER_FIELD_OUT" +} + +function test_compute_total_assertions_sums_into_slot() { + bashunit::runner::compute_total_assertions \ + "##ASSERTIONS_FAILED=1##ASSERTIONS_PASSED=2##ASSERTIONS_SKIPPED=3##ASSERTIONS_INCOMPLETE=4##ASSERTIONS_SNAPSHOT=5" + + assert_same "15" "$_BASHUNIT_RUNNER_TOTAL_OUT" +} + +function test_compute_total_assertions_treats_missing_counters_as_zero() { + bashunit::runner::compute_total_assertions "##ASSERTIONS_PASSED=2" + + assert_same "2" "$_BASHUNIT_RUNNER_TOTAL_OUT" +} + +# Without the digits guard the ##KEY= strips are no-ops on a non-payload result, +# so the raw text reaches $(( )) and raises a fatal arithmetic syntax error. +function test_compute_total_assertions_treats_a_non_payload_result_as_zero() { + bashunit::runner::compute_total_assertions "bash: line 3: syntax error near unexpected token" + + assert_same "0" "$_BASHUNIT_RUNNER_TOTAL_OUT" +} + +function test_compute_total_assertions_treats_a_non_numeric_counter_as_zero() { + bashunit::runner::compute_total_assertions "##ASSERTIONS_PASSED=2##ASSERTIONS_FAILED=oops" + + assert_same "0" "$_BASHUNIT_RUNNER_TOTAL_OUT" +} + +# Builds a one-line encoded test result like execute_test_body emits. +# Args: failed passed skipped incomplete snapshot exit_code +function build_encoded_result() { + local out="##ASSERTIONS_FAILED=$1##ASSERTIONS_PASSED=$2" + out="$out##ASSERTIONS_SKIPPED=$3##ASSERTIONS_INCOMPLETE=$4" + out="$out##ASSERTIONS_SNAPSHOT=$5##TEST_EXIT_CODE=$6##" + printf '%s' "$out" +} + +function test_extract_result_counts_writes_counts_to_slots() { + bashunit::runner::extract_result_counts "$(build_encoded_result 2 3 0 0 0 5)" + + assert_same "2" "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" + assert_same "3" "$_BASHUNIT_RUNNER_COUNTS_PASSED_OUT" + assert_same "5" "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" +} + +function test_extract_result_counts_does_not_mutate_cumulative_state() { + local before_failed="$_BASHUNIT_ASSERTIONS_FAILED" + local before_exit="$_BASHUNIT_TEST_EXIT_CODE" + + bashunit::runner::extract_result_counts "$(build_encoded_result 9 9 0 0 0 1)" + + assert_same "$before_failed" "$_BASHUNIT_ASSERTIONS_FAILED" + assert_same "$before_exit" "$_BASHUNIT_TEST_EXIT_CODE" +} + +function test_extract_result_counts_reads_only_the_last_line() { + local result + result="user output mentioning ASSERTIONS_FAILED=7 should be ignored +$(build_encoded_result 1 0 0 0 0 0)" + + bashunit::runner::extract_result_counts "$result" + + assert_same "1" "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" +} + +function test_extract_subshell_type_strips_brackets_into_slot() { + bashunit::runner::extract_subshell_type "[failed] something happened" + + assert_same "failed" "$_BASHUNIT_RUNNER_TYPE_OUT" +} + +function test_format_subshell_output_strips_type_and_expands_markers() { + bashunit::runner::format_subshell_output "[failed] line1[skipped]line2[incomplete]line3" + + local expected + expected=$' line1\nline2\nline3' + assert_same "$expected" "$_BASHUNIT_RUNNER_OUTPUT_OUT" +} + +# Regression for #674: caller-named locals must not be silently corrupted by +# the helpers. With the global-slot return pattern the helper never touches +# caller-named variables, so a caller can freely use natural names (e.g. +# `subshell_output`, `test_execution_result`) without any shadowing risk. +function test_format_subshell_output_does_not_touch_caller_locals() { + local subshell_output="raw" + bashunit::runner::format_subshell_output "[failed] formatted" + + assert_same " formatted" "$_BASHUNIT_RUNNER_OUTPUT_OUT" + assert_same "raw" "$subshell_output" +} + +function test_extract_subshell_type_does_not_touch_caller_locals() { + local subshell_output="[failed] payload" + bashunit::runner::extract_subshell_type "$subshell_output" + + assert_same "failed" "$_BASHUNIT_RUNNER_TYPE_OUT" + assert_same "[failed] payload" "$subshell_output" +} + +function test_extract_encoded_field_does_not_touch_caller_locals() { + local test_execution_result="##TEST_TITLE=hi##ASSERTIONS_PASSED=1" + bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_TITLE" + + assert_same "hi" "$_BASHUNIT_RUNNER_FIELD_OUT" + assert_same "##TEST_TITLE=hi##ASSERTIONS_PASSED=1" "$test_execution_result" +} + +function test_compute_total_assertions_does_not_touch_caller_locals() { + local test_execution_result="##ASSERTIONS_PASSED=4##ASSERTIONS_FAILED=1" + bashunit::runner::compute_total_assertions "$test_execution_result" + + assert_same "5" "$_BASHUNIT_RUNNER_TOTAL_OUT" + assert_same "##ASSERTIONS_PASSED=4##ASSERTIONS_FAILED=1" "$test_execution_result" +} diff --git a/tests/unit/runner_test.sh b/tests/unit/runner_test.sh deleted file mode 100644 index 31ddf545..00000000 --- a/tests/unit/runner_test.sh +++ /dev/null @@ -1,430 +0,0 @@ -#!/usr/bin/env bash - -function test_extract_assertion_runtime_output_keeps_user_output() { - local runtime_output - runtime_output=$'diagnostic from stderr\n✗ Failed: Example\n Expected '\''1'\''' - local rendered_assertion_output - rendered_assertion_output=$'✗ Failed: Example\n Expected '\''1'\''' - - local actual - actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" - - assert_same "diagnostic from stderr" "$actual" -} - -function test_extract_assertion_runtime_output_ignores_bashunit_status_output_before_failure() { - local runtime_output - runtime_output=$'✒ Incomplete: Example pending\n✗ Failed: Example\n Expected '\''1'\''' - local rendered_assertion_output - rendered_assertion_output=$'✒ Incomplete: Example pending\n✗ Failed: Example\n Expected '\''1'\''' - - local actual - actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" - - assert_empty "$actual" -} - -function test_extract_assertion_runtime_output_keeps_user_output_after_status_output() { - local runtime_output - runtime_output=$'✓ Passed: Previous assertion\ndiagnostic after pass\n✗ Failed: Example' - local rendered_assertion_output - rendered_assertion_output=$'✓ Passed: Previous assertion\n✗ Failed: Example' - - local actual - actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" - - assert_same "diagnostic after pass" "$actual" -} - -function test_extract_assertion_runtime_output_keeps_user_output_that_looks_like_status_output() { - local runtime_output - runtime_output=$'✗ Failed: emitted by the code under test\n✗ Failed: Example' - local rendered_assertion_output - rendered_assertion_output="✗ Failed: Example" - - local actual - actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" - - assert_same "✗ Failed: emitted by the code under test" "$actual" -} - -function test_sync_coverage_flag_sets_one_when_enabled() { - local _orig="${BASHUNIT_COVERAGE-}" - BASHUNIT_COVERAGE="true" - bashunit::runner::sync_coverage_flag - assert_same "1" "$_BASHUNIT_COVERAGE_ON" - BASHUNIT_COVERAGE="$_orig" - bashunit::runner::sync_coverage_flag -} - -function test_sync_coverage_flag_sets_zero_when_disabled() { - local _orig="${BASHUNIT_COVERAGE-}" - BASHUNIT_COVERAGE="false" - bashunit::runner::sync_coverage_flag - assert_same "0" "$_BASHUNIT_COVERAGE_ON" - BASHUNIT_COVERAGE="$_orig" - bashunit::runner::sync_coverage_flag -} - -function test_sync_coverage_flag_sets_zero_when_unset() { - local _orig="${BASHUNIT_COVERAGE-}" - unset BASHUNIT_COVERAGE - bashunit::runner::sync_coverage_flag - assert_same "0" "$_BASHUNIT_COVERAGE_ON" - BASHUNIT_COVERAGE="$_orig" - bashunit::runner::sync_coverage_flag -} - -function test_detect_runtime_error_returns_empty_when_input_is_empty() { - bashunit::runner::detect_runtime_error "" - - assert_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" -} - -function test_detect_runtime_error_returns_empty_when_no_known_error() { - bashunit::runner::detect_runtime_error "all good here" - - assert_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" -} - -function test_detect_runtime_error_matches_command_not_found() { - bashunit::runner::detect_runtime_error "script.sh: line 3: foo: command not found" - - assert_same "line 3: foo: command not found" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" -} - -function test_detect_runtime_error_matches_syntax_error() { - bashunit::runner::detect_runtime_error "bash: -c: line 1: syntax error near unexpected token" - - assert_same "-c: line 1: syntax error near unexpected token" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" -} - -function test_detect_runtime_error_matches_killed() { - bashunit::runner::detect_runtime_error "process: killed" - - assert_same "killed" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" -} - -function test_detect_runtime_error_strips_newlines_from_extracted_message() { - local input=$'bash: line 1: foo: command not found\nextra' - bashunit::runner::detect_runtime_error "$input" - - assert_same "line 1: foo: command not foundextra" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" -} - -function test_detect_runtime_error_matches_unexpected_eof() { - bashunit::runner::detect_runtime_error "bash: line 5: unexpected EOF while looking for matching" - - assert_same "line 5: unexpected EOF while looking for matching" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" -} - -function test_decode_subshell_output_writes_empty_for_empty_marker() { - bashunit::runner::decode_subshell_output "pre##TEST_OUTPUT=_BASHUNIT_EMPTY_##ASSERTIONS_PASSED=1" - - assert_empty "$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT" -} - -function test_decode_subshell_output_decodes_non_empty_output_to_slot() { - local encoded - encoded="$(bashunit::helper::encode_base64 "hello output")" - bashunit::runner::decode_subshell_output "pre##TEST_OUTPUT=${encoded}##ASSERTIONS_PASSED=1" - - assert_same "hello output" "$_BASHUNIT_RUNNER_SUBSHELL_OUTPUT_OUT" -} - -function test_extract_encoded_field_writes_value_to_slot() { - bashunit::runner::extract_encoded_field \ - "preamble##TEST_TITLE=hello world##ASSERTIONS_PASSED=1" "TEST_TITLE" - - assert_same "hello world" "$_BASHUNIT_RUNNER_FIELD_OUT" -} - -function test_extract_encoded_field_writes_empty_when_key_missing() { - _BASHUNIT_RUNNER_FIELD_OUT="prior" - bashunit::runner::extract_encoded_field "##ASSERTIONS_PASSED=1" "TEST_TITLE" - - assert_empty "$_BASHUNIT_RUNNER_FIELD_OUT" -} - -function test_compute_total_assertions_sums_into_slot() { - bashunit::runner::compute_total_assertions \ - "##ASSERTIONS_FAILED=1##ASSERTIONS_PASSED=2##ASSERTIONS_SKIPPED=3##ASSERTIONS_INCOMPLETE=4##ASSERTIONS_SNAPSHOT=5" - - assert_same "15" "$_BASHUNIT_RUNNER_TOTAL_OUT" -} - -function test_compute_total_assertions_treats_missing_counters_as_zero() { - bashunit::runner::compute_total_assertions "##ASSERTIONS_PASSED=2" - - assert_same "2" "$_BASHUNIT_RUNNER_TOTAL_OUT" -} - -# Without the digits guard the ##KEY= strips are no-ops on a non-payload result, -# so the raw text reaches $(( )) and raises a fatal arithmetic syntax error. -function test_compute_total_assertions_treats_a_non_payload_result_as_zero() { - bashunit::runner::compute_total_assertions "bash: line 3: syntax error near unexpected token" - - assert_same "0" "$_BASHUNIT_RUNNER_TOTAL_OUT" -} - -function test_compute_total_assertions_treats_a_non_numeric_counter_as_zero() { - bashunit::runner::compute_total_assertions "##ASSERTIONS_PASSED=2##ASSERTIONS_FAILED=oops" - - assert_same "0" "$_BASHUNIT_RUNNER_TOTAL_OUT" -} - -# Builds a one-line encoded test result like execute_test_body emits. -# Args: failed passed skipped incomplete snapshot exit_code -function build_encoded_result() { - local out="##ASSERTIONS_FAILED=$1##ASSERTIONS_PASSED=$2" - out="$out##ASSERTIONS_SKIPPED=$3##ASSERTIONS_INCOMPLETE=$4" - out="$out##ASSERTIONS_SNAPSHOT=$5##TEST_EXIT_CODE=$6##" - printf '%s' "$out" -} - -function test_extract_result_counts_writes_counts_to_slots() { - bashunit::runner::extract_result_counts "$(build_encoded_result 2 3 0 0 0 5)" - - assert_same "2" "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" - assert_same "3" "$_BASHUNIT_RUNNER_COUNTS_PASSED_OUT" - assert_same "5" "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" -} - -function test_extract_result_counts_does_not_mutate_cumulative_state() { - local before_failed="$_BASHUNIT_ASSERTIONS_FAILED" - local before_exit="$_BASHUNIT_TEST_EXIT_CODE" - - bashunit::runner::extract_result_counts "$(build_encoded_result 9 9 0 0 0 1)" - - assert_same "$before_failed" "$_BASHUNIT_ASSERTIONS_FAILED" - assert_same "$before_exit" "$_BASHUNIT_TEST_EXIT_CODE" -} - -function test_extract_result_counts_reads_only_the_last_line() { - local result - result="user output mentioning ASSERTIONS_FAILED=7 should be ignored -$(build_encoded_result 1 0 0 0 0 0)" - - bashunit::runner::extract_result_counts "$result" - - assert_same "1" "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" -} - -function test_extract_subshell_type_strips_brackets_into_slot() { - bashunit::runner::extract_subshell_type "[failed] something happened" - - assert_same "failed" "$_BASHUNIT_RUNNER_TYPE_OUT" -} - -function test_format_subshell_output_strips_type_and_expands_markers() { - bashunit::runner::format_subshell_output "[failed] line1[skipped]line2[incomplete]line3" - - local expected - expected=$' line1\nline2\nline3' - assert_same "$expected" "$_BASHUNIT_RUNNER_OUTPUT_OUT" -} - -# Regression for #674: caller-named locals must not be silently corrupted by -# the helpers. With the global-slot return pattern the helper never touches -# caller-named variables, so a caller can freely use natural names (e.g. -# `subshell_output`, `test_execution_result`) without any shadowing risk. -function test_format_subshell_output_does_not_touch_caller_locals() { - local subshell_output="raw" - bashunit::runner::format_subshell_output "[failed] formatted" - - assert_same " formatted" "$_BASHUNIT_RUNNER_OUTPUT_OUT" - assert_same "raw" "$subshell_output" -} - -function test_extract_subshell_type_does_not_touch_caller_locals() { - local subshell_output="[failed] payload" - bashunit::runner::extract_subshell_type "$subshell_output" - - assert_same "failed" "$_BASHUNIT_RUNNER_TYPE_OUT" - assert_same "[failed] payload" "$subshell_output" -} - -function test_extract_encoded_field_does_not_touch_caller_locals() { - local test_execution_result="##TEST_TITLE=hi##ASSERTIONS_PASSED=1" - bashunit::runner::extract_encoded_field "$test_execution_result" "TEST_TITLE" - - assert_same "hi" "$_BASHUNIT_RUNNER_FIELD_OUT" - assert_same "##TEST_TITLE=hi##ASSERTIONS_PASSED=1" "$test_execution_result" -} - -function test_compute_total_assertions_does_not_touch_caller_locals() { - local test_execution_result="##ASSERTIONS_PASSED=4##ASSERTIONS_FAILED=1" - bashunit::runner::compute_total_assertions "$test_execution_result" - - assert_same "5" "$_BASHUNIT_RUNNER_TOTAL_OUT" - assert_same "##ASSERTIONS_PASSED=4##ASSERTIONS_FAILED=1" "$test_execution_result" -} - -function test_classify_kill_signal_sigkill_mentions_oom() { - local output - output="$(bashunit::runner::classify_kill_signal 137)" - - assert_contains "SIGKILL" "$output" - assert_contains "memory" "$output" -} - -function test_classify_kill_signal_sigterm() { - assert_contains "SIGTERM" "$(bashunit::runner::classify_kill_signal 143)" -} - -function test_classify_kill_signal_timeout() { - assert_contains "Timed out" "$(bashunit::runner::classify_kill_signal 124)" -} - -function test_classify_kill_signal_sigint() { - assert_contains "SIGINT" "$(bashunit::runner::classify_kill_signal 130)" -} - -function test_classify_kill_signal_generic_signal() { - assert_contains "signal 6" "$(bashunit::runner::classify_kill_signal 134)" -} - -function test_classify_kill_signal_empty_for_normal_exit() { - assert_empty "$(bashunit::runner::classify_kill_signal 1)" -} - -function test_supports_reliable_pipefail_matches_bash_version() { - # Reliable on Bash >= 3.1; Bash 3.0 ships a broken pipefail. - local expected_rc=0 - if [ "${BASH_VERSINFO[0]}" -eq 3 ] && [ "${BASH_VERSINFO[1]}" -eq 0 ]; then - expected_rc=1 - fi - - local actual_rc=0 - bashunit::runner::_supports_reliable_pipefail || actual_rc=$? - assert_same "$expected_rc" "$actual_rc" -} - -function test_functions_for_script_sorts_by_definition_line() { - local fixture - fixture="$(bashunit::temp_dir)/ffs_order_fixture.sh" - { - echo 'function test_zebra() { :; }' - echo 'function test_alpha() { :; }' - echo 'function test_mid() { :; }' - } >"$fixture" - # shellcheck source=/dev/null - source "$fixture" - - local actual - actual="$(bashunit::runner::functions_for_script "$fixture" "test_alpha test_mid test_zebra")" - - assert_same $'test_zebra\ntest_alpha\ntest_mid' "$actual" -} - -function test_functions_for_script_filters_out_functions_from_other_files() { - local fixture - fixture="$(bashunit::temp_dir)/ffs_filter_fixture.sh" - echo 'function test_only_mine() { :; }' >"$fixture" - # shellcheck source=/dev/null - source "$fixture" - - # test_functions_for_script_filters_out_functions_from_other_files is defined - # in this test file, not in the fixture, so it must be filtered out. - local actual - actual="$(bashunit::runner::functions_for_script "$fixture" \ - "test_only_mine test_functions_for_script_filters_out_functions_from_other_files")" - - assert_same "test_only_mine" "$actual" -} - -function test_functions_for_script_preserves_caller_extdebug_state() { - local fixture - fixture="$(bashunit::temp_dir)/ffs_extdebug_fixture.sh" - echo 'function test_ffs_extdebug() { :; }' >"$fixture" - # shellcheck source=/dev/null - source "$fixture" - - # Toggle inside a subshell so the runner's own shell is never touched. - local state - state=$( - shopt -s extdebug - bashunit::runner::functions_for_script "$fixture" "test_ffs_extdebug" >/dev/null - if shopt -q extdebug; then echo "on"; else echo "off"; fi - ) - - assert_same "on" "$state" -} - -# --- result-line layout contract --------------------------------------------- -# The "##KEY=value##" subshell result record has two writers: -# bashunit::state::export_subshell_context (canonical) and the timeout-path copy -# bashunit::runner::build_timeout_result. They live in different files, so a -# field added to one silently shortens the record the other emits, and every -# reader (runner::extract_result_counts, state::aggregate_parallel_results) -# then mis-parses timed-out tests. Folding the two writers into one function -# would put a call on the per-test hot path that #762/#764 deliberately -# stripped; pinning the layout costs nothing at runtime and fails the moment -# the copies diverge. - -# Echoes the ordered field keys of a "##KEY=value##..." record, one per line. -function result_line_field_keys() { - local rest="$1" - local key - while [ -n "$rest" ]; do - case "$rest" in - *"##"*) rest="${rest#*##}" ;; - *) break ;; - esac - key="${rest%%=*}" - case "$key" in - "$rest") continue ;; - esac - printf '%s\n' "$key" - done -} - -function test_timeout_result_uses_the_same_field_layout_as_the_state_writer() { - local canonical - canonical="$(bashunit::state::export_subshell_context)" - local timeout_line - timeout_line="$(bashunit::runner::build_timeout_result)" - - assert_same \ - "$(result_line_field_keys "$canonical")" \ - "$(result_line_field_keys "$timeout_line")" -} - -function test_timeout_result_reports_the_conventional_timed_out_exit_code() { - bashunit::runner::extract_result_counts "$(bashunit::runner::build_timeout_result)" - - assert_same "124" "$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT" - assert_same "0" "$_BASHUNIT_RUNNER_COUNTS_FAILED_OUT" - assert_same "0" "$_BASHUNIT_RUNNER_COUNTS_PASSED_OUT" -} - -# --- restore_workdir ---------------------------------------------------------- - -function test_restore_workdir_returns_to_the_given_directory() { - local target - target="$(bashunit::temp_dir)" - - local landed - landed=$( - cd / || exit 1 - bashunit::runner::restore_workdir "$target" - pwd -P - ) - - assert_same "$(cd "$target" && pwd -P)" "$landed" -} - -function test_restore_workdir_aborts_loudly_when_the_directory_is_gone() { - local gone - gone="$(bashunit::temp_dir)/removed" - - local status=0 - local output - # `$(...)` is already a subshell, so the function's `exit 1` ends the capture - # rather than the test. - output="$(bashunit::runner::restore_workdir "$gone" 2>&1)" || status=$? - - assert_same 1 "$status" - assert_contains "cannot restore the working directory" "$output" - assert_contains "$gone" "$output" -}