Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@

### Changed
- Split Windows CI test jobs into parallel chunks to avoid timeouts
- Optimize clock: prioritize EPOCHREALTIME over subprocess-based fallbacks
- Cache function discovery to avoid duplicate pipeline per test file
- Reduce subshells in test execution hot path
- Batch coverage recording with in-memory buffering

### Fixed
- JUnit XML report now conforms to the standard schema
Expand Down
51 changes: 26 additions & 25 deletions src/clock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,58 +8,59 @@ function bashunit::clock::_choose_impl() {
local attempts_count=0
local attempts

# 1. Try Perl with Time::HiRes
# 1. Try native shell EPOCHREALTIME (fastest - no subprocess, Bash 5.0+)
attempts[attempts_count]="EPOCHREALTIME"
attempts_count=$((attempts_count + 1))
if shell_time="$(bashunit::clock::shell_time)"; then
_BASHUNIT_CLOCK_NOW_IMPL="shell"
return 0
fi

# 2. Unix date +%s%N (no subprocess overhead on supported systems)
attempts[attempts_count]="date"
attempts_count=$((attempts_count + 1))
if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then
local result
result=$(date +%s%N 2>/dev/null)
local _re='^[0-9]+$'
if [[ "$result" != *N ]] && [[ "$result" =~ $_re ]]; then
_BASHUNIT_CLOCK_NOW_IMPL="date"
return 0
fi
fi

# 3. Try Perl with Time::HiRes
attempts[attempts_count]="Perl"
attempts_count=$((attempts_count + 1))
if bashunit::dependencies::has_perl && perl -MTime::HiRes -e "" &>/dev/null; then
_BASHUNIT_CLOCK_NOW_IMPL="perl"
return 0
fi

# 2. Try Python 3 with time module
# 4. Try Python 3 with time module
attempts[attempts_count]="Python"
attempts_count=$((attempts_count + 1))
if bashunit::dependencies::has_python; then
_BASHUNIT_CLOCK_NOW_IMPL="python"
return 0
fi

# 3. Try Node.js
# 5. Try Node.js
attempts[attempts_count]="Node"
attempts_count=$((attempts_count + 1))
if bashunit::dependencies::has_node; then
_BASHUNIT_CLOCK_NOW_IMPL="node"
return 0
fi
# 4. Windows fallback with PowerShell

# 6. Windows fallback with PowerShell
attempts[attempts_count]="PowerShell"
attempts_count=$((attempts_count + 1))
if bashunit::check_os::is_windows && bashunit::dependencies::has_powershell; then
_BASHUNIT_CLOCK_NOW_IMPL="powershell"
return 0
fi

# 5. Unix fallback using `date +%s%N` (if not macOS or Alpine)
attempts[attempts_count]="date"
attempts_count=$((attempts_count + 1))
if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then
local result
result=$(date +%s%N 2>/dev/null)
local _re='^[0-9]+$'
if [[ "$result" != *N ]] && [[ "$result" =~ $_re ]]; then
_BASHUNIT_CLOCK_NOW_IMPL="date"
return 0
fi
fi

# 6. Try using native shell EPOCHREALTIME (if available)
attempts[attempts_count]="EPOCHREALTIME"
attempts_count=$((attempts_count + 1))
if shell_time="$(bashunit::clock::shell_time)"; then
_BASHUNIT_CLOCK_NOW_IMPL="shell"
return 0
fi

# 7. Very last fallback: seconds resolution only
attempts[attempts_count]="date-seconds"
attempts_count=$((attempts_count + 1))
Expand Down
106 changes: 91 additions & 15 deletions src/coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-
# File to store which tests hit each line (for detailed coverage tooltips)
_BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}"

# In-memory buffer for coverage data (reduces file I/O)
_BASHUNIT_COVERAGE_BUFFER=""
_BASHUNIT_COVERAGE_BUFFER_COUNT=0
_BASHUNIT_COVERAGE_BUFFER_LIMIT=100
_BASHUNIT_COVERAGE_HITS_BUFFER=""

# In-memory caches for hot-path lookups (avoids grep + subshells)
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""

# Auto-discover coverage paths from test file names
# When no explicit coverage paths are set, find source files matching test file base names
# Example: tests/unit/assert_test.sh -> finds src/assert.sh, src/assert_*.sh
Expand Down Expand Up @@ -82,6 +93,14 @@ function bashunit::coverage::init() {
: >"$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE"
: >"$_BASHUNIT_COVERAGE_TEST_HITS_FILE"

# Reset in-memory caches and buffers
_BASHUNIT_COVERAGE_BUFFER=""
_BASHUNIT_COVERAGE_BUFFER_COUNT=0
_BASHUNIT_COVERAGE_HITS_BUFFER=""
_BASHUNIT_COVERAGE_TRACK_CACHE=""
_BASHUNIT_COVERAGE_PATH_CACHE=""
_BASHUNIT_COVERAGE_IS_PARALLEL=""

export _BASHUNIT_COVERAGE_DATA_FILE
export _BASHUNIT_COVERAGE_TRACKED_FILES
export _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE
Expand All @@ -105,6 +124,8 @@ function bashunit::coverage::enable_trap() {
function bashunit::coverage::disable_trap() {
trap - DEBUG
set +T
# Flush any remaining buffered coverage data
bashunit::coverage::flush_buffer
}

# Normalize file path to absolute
Expand Down Expand Up @@ -171,31 +192,86 @@ function bashunit::coverage::record_line() {
# Skip if coverage data file doesn't exist (trap inherited by child process)
[[ -z "$_BASHUNIT_COVERAGE_DATA_FILE" ]] && return 0

# Skip if not tracking this file (uses cache internally)
bashunit::coverage::should_track "$file" || return 0
# Fast in-memory should_track cache (avoids grep + file I/O per line)
case "$_BASHUNIT_COVERAGE_TRACK_CACHE" in
*"|${file}:0|"*) return 0 ;;
*"|${file}:1|"*) ;;
*)
# Not cached yet — run full check and cache result
if bashunit::coverage::should_track "$file"; then
_BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:1|"
else
_BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:0|"
return 0
fi
;;
esac

# Normalize file path using cache (must match tracked_files for hit counting)
local normalized_file
normalized_file=$(bashunit::coverage::normalize_path "$file")
# Fast in-memory path normalization cache (avoids cd + pwd subshell per line)
local normalized_file=""
case "$_BASHUNIT_COVERAGE_PATH_CACHE" in
*"|${file}="*)
# Extract cached value
normalized_file="${_BASHUNIT_COVERAGE_PATH_CACHE#*"|${file}="}"
normalized_file="${normalized_file%%"|"*}"
;;
*)
normalized_file=$(bashunit::coverage::normalize_path "$file")
_BASHUNIT_COVERAGE_PATH_CACHE="${_BASHUNIT_COVERAGE_PATH_CACHE}|${file}=${normalized_file}|"
;;
esac

# In parallel mode, use a per-process file to avoid race conditions
# Buffer the coverage data in memory
_BASHUNIT_COVERAGE_BUFFER="${_BASHUNIT_COVERAGE_BUFFER}${normalized_file}:${lineno}
"
# Also buffer test hit data if in a test context
if [[ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" && \
-n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]]; then
_BASHUNIT_COVERAGE_HITS_BUFFER="${_BASHUNIT_COVERAGE_HITS_BUFFER}${normalized_file}:${lineno}|${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}
"
fi

_BASHUNIT_COVERAGE_BUFFER_COUNT=$((_BASHUNIT_COVERAGE_BUFFER_COUNT + 1))

# Flush buffer to disk when threshold is reached
if [[ $_BASHUNIT_COVERAGE_BUFFER_COUNT -ge \
$_BASHUNIT_COVERAGE_BUFFER_LIMIT ]]; then
bashunit::coverage::flush_buffer
fi
}

function bashunit::coverage::flush_buffer() {
[[ -z "$_BASHUNIT_COVERAGE_BUFFER" ]] && return 0

# Determine output files (parallel-safe)
local data_file="$_BASHUNIT_COVERAGE_DATA_FILE"
local test_hits_file="$_BASHUNIT_COVERAGE_TEST_HITS_FILE"
if bashunit::parallel::is_enabled; then

# Cache the parallel check to avoid function calls
if [[ -z "$_BASHUNIT_COVERAGE_IS_PARALLEL" ]]; then
if bashunit::parallel::is_enabled; then
_BASHUNIT_COVERAGE_IS_PARALLEL="yes"
else
_BASHUNIT_COVERAGE_IS_PARALLEL="no"
fi
fi

if [[ "$_BASHUNIT_COVERAGE_IS_PARALLEL" == "yes" ]]; then
data_file="${_BASHUNIT_COVERAGE_DATA_FILE}.$$"
test_hits_file="${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.$$"
fi

# Record the hit (only if parent directory exists)
if [[ -d "$(dirname "$data_file")" ]]; then
echo "${normalized_file}:${lineno}" >>"$data_file"
# Write buffered data in a single I/O operation
printf '%s' "$_BASHUNIT_COVERAGE_BUFFER" >>"$data_file"

# Also record which test caused this hit (if we're in a test context)
if [[ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" && -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]]; then
# Format: source_file:line|test_file:test_function
echo "${normalized_file}:${lineno}|${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}" >>"$test_hits_file"
fi
if [[ -n "$_BASHUNIT_COVERAGE_HITS_BUFFER" ]]; then
printf '%s' "$_BASHUNIT_COVERAGE_HITS_BUFFER" >>"$test_hits_file"
fi

# Reset buffer
_BASHUNIT_COVERAGE_BUFFER=""
_BASHUNIT_COVERAGE_HITS_BUFFER=""
_BASHUNIT_COVERAGE_BUFFER_COUNT=0
}

function bashunit::coverage::should_track() {
Expand Down
99 changes: 66 additions & 33 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,15 @@ function bashunit::runner::load_test_files() {
bashunit::runner::restore_workdir
continue
fi
local _cached_fns="$functions_for_script"
if bashunit::parallel::is_enabled; then
bashunit::runner::call_test_functions "$test_file" "$filter" "$tag_filter" "$exclude_tag_filter" 2>/dev/null &
bashunit::runner::call_test_functions \
"$test_file" "$filter" "$tag_filter" \
"$exclude_tag_filter" "$_cached_fns" 2>/dev/null &
else
bashunit::runner::call_test_functions "$test_file" "$filter" "$tag_filter" "$exclude_tag_filter"
bashunit::runner::call_test_functions \
"$test_file" "$filter" "$tag_filter" \
"$exclude_tag_filter" "$_cached_fns"
fi
bashunit::runner::run_tear_down_after_script "$test_file"
bashunit::runner::clean_set_up_and_tear_down_after_script
Expand Down Expand Up @@ -343,36 +348,48 @@ function bashunit::runner::call_test_functions() {
local filter="$2"
local tag_filter="${3:-}"
local exclude_tag_filter="${4:-}"
local cached_functions="${5:-}"
local IFS=$' \t\n'
local prefix="test"
# 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")

# Apply tag filtering if --tag or --exclude-tag was specified
if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then
local -a tag_filtered=()
local tag_filtered_count=0
local _tf_fn
for _tf_fn in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do
local fn_tags
fn_tags=$(bashunit::helper::get_tags_for_function "$_tf_fn" "$script")
if bashunit::helper::function_matches_tags "$fn_tags" "$tag_filter" "$exclude_tag_filter"; then
tag_filtered[tag_filtered_count]="$_tf_fn"
tag_filtered_count=$((tag_filtered_count + 1))
fi
if [[ -n "$cached_functions" ]]; then
# Use pre-computed function list from load_test_files (already tag-filtered)
local _fn
for _fn in $cached_functions; do
[[ -z "$_fn" ]] && continue
functions_to_run[functions_to_run_count]="$_fn"
functions_to_run_count=$((functions_to_run_count + 1))
done
functions_to_run=("${tag_filtered[@]+"${tag_filtered[@]}"}")
functions_to_run_count=$tag_filtered_count
else
# Fallback: compute function list (for direct calls without cache)
local prefix="test"
local filtered_functions
filtered_functions=$(bashunit::helper::get_functions_to_run \
"$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS")
local _fn
while IFS= read -r _fn; do
[[ -z "$_fn" ]] && continue
functions_to_run[functions_to_run_count]="$_fn"
functions_to_run_count=$((functions_to_run_count + 1))
done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions")

# Apply tag filtering if --tag or --exclude-tag was specified
if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then
local -a tag_filtered=()
local tag_filtered_count=0
local _tf_fn
for _tf_fn in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do
local fn_tags
fn_tags=$(bashunit::helper::get_tags_for_function "$_tf_fn" "$script")
if bashunit::helper::function_matches_tags "$fn_tags" "$tag_filter" "$exclude_tag_filter"; then
tag_filtered[tag_filtered_count]="$_tf_fn"
tag_filtered_count=$((tag_filtered_count + 1))
fi
done
functions_to_run=("${tag_filtered[@]+"${tag_filtered[@]}"}")
functions_to_run_count=$tag_filtered_count
fi
fi

if [[ "$functions_to_run_count" -le 0 ]]; then
Expand Down Expand Up @@ -541,10 +558,10 @@ function bashunit::runner::run_test() {
else
bashunit::state::reset_current_test_interpolated_function_name
fi
local current_assertions_failed="$(bashunit::state::get_assertions_failed)"
local current_assertions_snapshot="$(bashunit::state::get_assertions_snapshot)"
local current_assertions_incomplete="$(bashunit::state::get_assertions_incomplete)"
local current_assertions_skipped="$(bashunit::state::get_assertions_skipped)"
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.
Expand Down Expand Up @@ -655,8 +672,24 @@ function bashunit::runner::run_test() {

bashunit::runner::parse_result "$fn_name" "$test_execution_result" "$@"

local total_assertions="$(bashunit::state::calculate_total_assertions "$test_execution_result")"
local test_exit_code="$(bashunit::state::get_test_exit_code)"
local test_exit_code="$_BASHUNIT_TEST_EXIT_CODE"

# Extract assertion counts directly via parameter expansion
# instead of spawning grep subprocesses
local _te_failed="${test_execution_result##*##ASSERTIONS_FAILED=}"
_te_failed="${_te_failed%%##*}"
local _te_passed="${test_execution_result##*##ASSERTIONS_PASSED=}"
_te_passed="${_te_passed%%##*}"
local _te_skipped="${test_execution_result##*##ASSERTIONS_SKIPPED=}"
_te_skipped="${_te_skipped%%##*}"
local _te_incomplete="${test_execution_result##*##ASSERTIONS_INCOMPLETE=}"
_te_incomplete="${_te_incomplete%%##*}"
local _te_snapshot="${test_execution_result##*##ASSERTIONS_SNAPSHOT=}"
_te_snapshot="${_te_snapshot%%##*}"
local total_assertions=$(( \
${_te_failed:-0} + ${_te_passed:-0} + ${_te_skipped:-0} + \
${_te_incomplete:-0} + ${_te_snapshot:-0} \
))

local encoded_test_title
encoded_test_title="${test_execution_result##*##TEST_TITLE=}"
Expand Down
Loading
Loading