diff --git a/CHANGELOG.md b/CHANGELOG.md index 48b294c1..3f949b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/clock.sh b/src/clock.sh index d7978559..510040ca 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -8,7 +8,28 @@ 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 @@ -16,7 +37,7 @@ function bashunit::clock::_choose_impl() { 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 @@ -24,14 +45,15 @@ function bashunit::clock::_choose_impl() { 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 @@ -39,27 +61,6 @@ function bashunit::clock::_choose_impl() { 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)) diff --git a/src/coverage.sh b/src/coverage.sh index 2b06199f..21c51213 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -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 @@ -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 @@ -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 @@ -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() { diff --git a/src/runner.sh b/src/runner.sh index feabc501..e3ddaea4 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -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 @@ -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 @@ -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. @@ -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=}" diff --git a/tests/unit/clock_test.sh b/tests/unit/clock_test.sh index ba94d67a..39f52c70 100644 --- a/tests/unit/clock_test.sh +++ b/tests/unit/clock_test.sh @@ -6,6 +6,10 @@ function set_up_before_script() { __ORIGINAL_OS=$_BASHUNIT_OS } +function set_up() { + _BASHUNIT_CLOCK_NOW_IMPL="" +} + function tear_down_after_script() { export _BASHUNIT_OS=$__ORIGINAL_OS } @@ -24,6 +28,7 @@ function mock_date_seconds() { function test_now_with_perl() { bashunit::mock bashunit::clock::shell_time mock_non_existing_fn + bashunit::mock date mock_non_existing_fn bashunit::mock perl <<<"1720705883457" bashunit::mock bashunit::dependencies::has_python mock_false bashunit::mock bashunit::dependencies::has_node mock_false @@ -60,6 +65,7 @@ function test_now_on_windows_without_with_powershell() { bashunit::mock bashunit::clock::shell_time mock_non_existing_fn bashunit::mock bashunit::dependencies::has_python mock_false bashunit::mock bashunit::dependencies::has_node mock_false + bashunit::mock date mock_non_existing_fn assert_same "1727768183281580800" "$(bashunit::clock::now)" } @@ -68,10 +74,10 @@ function test_now_on_windows_without_without_powershell() { mock_windows_os bashunit::mock bashunit::dependencies::has_perl mock_false bashunit::mock bashunit::dependencies::has_powershell mock_false - bashunit::mock date <<<"1727768951" bashunit::mock bashunit::clock::shell_time mock_non_existing_fn bashunit::mock bashunit::dependencies::has_python mock_false bashunit::mock bashunit::dependencies::has_node mock_false + bashunit::mock date <<<"1727768951" assert_same "1727768951" "$(bashunit::clock::now)" } @@ -109,16 +115,18 @@ function test_runtime_in_milliseconds_when_not_empty_time() { assert_not_empty "$(bashunit::clock::total_runtime_in_milliseconds)" } -function test_now_prefers_perl_over_shell_time() { - bashunit::mock bashunit::clock::shell_time <<<"1234.0" +function test_now_prefers_shell_time_over_perl() { + bashunit::mock bashunit::clock::shell_time <<<"1234.567890" bashunit::mock perl <<<"999999999999" bashunit::mock bashunit::dependencies::has_python mock_false bashunit::mock bashunit::dependencies::has_node mock_false - assert_same "999999999999" "$(bashunit::clock::now)" + assert_same "1234567890000" "$(bashunit::clock::now)" } function test_now_prefers_python_over_node() { + bashunit::mock bashunit::clock::shell_time mock_non_existing_fn + bashunit::mock date mock_non_existing_fn bashunit::mock perl mock_non_existing_fn bashunit::mock bashunit::dependencies::has_python mock_true bashunit::mock python <<<"777777777777" diff --git a/tests/unit/coverage_core_test.sh b/tests/unit/coverage_core_test.sh index 88c3e80a..73183465 100644 --- a/tests/unit/coverage_core_test.sh +++ b/tests/unit/coverage_core_test.sh @@ -185,6 +185,9 @@ function test_coverage_record_line_writes_to_file() { bashunit::coverage::record_line "$test_file" "20" bashunit::coverage::record_line "$test_file" "10" + # Flush buffered coverage data to disk before reading + bashunit::coverage::flush_buffer + # In parallel mode, data is written to a per-process file local data_file="$_BASHUNIT_COVERAGE_DATA_FILE" if bashunit::parallel::is_enabled; then