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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
perf: P0 performance optimizations by Chemaclass · Pull Request #598 · TypedDevs/bashunit · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' perf: P0 performance optimizations by Chemaclass · Pull Request #598 · TypedDevs/bashunit · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' perf: P0 performance optimizations by Chemaclass · Pull Request #598 · TypedDevs/bashunit · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' perf: P0 performance optimizations by Chemaclass · Pull Request #598 · TypedDevs/bashunit · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' perf: P0 performance optimizations by Chemaclass · Pull Request #598 · TypedDevs/bashunit · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' perf: P0 performance optimizations by Chemaclass · Pull Request #598 · TypedDevs/bashunit · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); perf: P0 performance optimizations by Chemaclass · Pull Request #598 · TypedDevs/bashunit · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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