diff --git a/.editorconfig b/.editorconfig index 02464a36..0ab7298b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -28,3 +28,6 @@ indent_style = tab [{tests/acceptance/**.sh,src/console_header.sh,docs/command-line.md}] indent_size = unset + +[src/coverage.sh] +max_line_length = unset diff --git a/CHANGELOG.md b/CHANGELOG.md index 372ca944..9c926a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Better code coverage HTML report + ## [0.31.0](https://github.com/TypedDevs/bashunit/compare/0.30.0...0.31.0) - 2025-12-19 ### Added diff --git a/src/coverage.sh b/src/coverage.sh index 545522ad..3ad43da6 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -10,6 +10,9 @@ _BASHUNIT_COVERAGE_TRACKED_FILES="${_BASHUNIT_COVERAGE_TRACKED_FILES:-}" # The tracked cache file stores files that have already been processed _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:-}" + # Store the subshell level when coverage trap is enabled # Used to skip recording in nested subshells (command substitution) # Uses $BASH_SUBSHELL which is Bash 3.2 compatible (unlike $BASHPID) @@ -36,15 +39,18 @@ function bashunit::coverage::init() { _BASHUNIT_COVERAGE_DATA_FILE="${coverage_dir}/hits.dat" _BASHUNIT_COVERAGE_TRACKED_FILES="${coverage_dir}/files.dat" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${coverage_dir}/cache.dat" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="${coverage_dir}/test_hits.dat" # Initialize empty files : > "$_BASHUNIT_COVERAGE_DATA_FILE" : > "$_BASHUNIT_COVERAGE_TRACKED_FILES" : > "$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + : > "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" export _BASHUNIT_COVERAGE_DATA_FILE export _BASHUNIT_COVERAGE_TRACKED_FILES export _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE + export _BASHUNIT_COVERAGE_TEST_HITS_FILE } function bashunit::coverage::enable_trap() { @@ -107,12 +113,22 @@ function bashunit::coverage::record_line() { # In parallel mode, use a per-process file to avoid race conditions local data_file="$_BASHUNIT_COVERAGE_DATA_FILE" + local test_hits_file="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" if bashunit::parallel::is_enabled; then data_file="${_BASHUNIT_COVERAGE_DATA_FILE}.$$" + test_hits_file="${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.$$" fi # Record the hit (only if parent directory exists) - [[ -d "$(dirname "$data_file")" ]] && echo "${normalized_file}:${lineno}" >> "$data_file" + if [[ -d "$(dirname "$data_file")" ]]; then + echo "${normalized_file}:${lineno}" >> "$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 + fi } function bashunit::coverage::should_track() { @@ -213,6 +229,7 @@ function bashunit::coverage::aggregate_parallel() { # Aggregate per-process coverage files created during parallel execution local base_file="$_BASHUNIT_COVERAGE_DATA_FILE" local tracked_base="$_BASHUNIT_COVERAGE_TRACKED_FILES" + local test_hits_base="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" # Find and merge all per-process coverage data files # Use nullglob to handle case when no files match @@ -236,6 +253,18 @@ function bashunit::coverage::aggregate_parallel() { done <<< "$pid_files" fi + # Find and merge all per-process test hits files + if [[ -n "$test_hits_base" ]]; then + pid_files=$(ls -1 "${test_hits_base}."* 2>/dev/null) || true + if [[ -n "$pid_files" ]]; then + while IFS= read -r pid_file; do + [[ -f "$pid_file" ]] || continue + cat "$pid_file" >> "$test_hits_base" + rm -f "$pid_file" + done <<< "$pid_files" + fi + fi + # Deduplicate tracked files if [[ -f "$tracked_base" ]]; then sort -u "$tracked_base" -o "$tracked_base" @@ -350,6 +379,21 @@ function bashunit::coverage::get_all_line_hits() { done } +# Get all test hits for a file in one pass (performance optimization) +# Output format: lineno|test_file:test_function (may have duplicates, one per hit) +function bashunit::coverage::get_all_line_tests() { + local file="$1" + + if [[ ! -f "${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" ]]; then + return + fi + + # Format in file: source_file:line|test_file:test_function + # Output: lineno|test_file:test_function + grep "^${file}:" "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 2>/dev/null | \ + sed "s|^${file}:||" | sort -u +} + function bashunit::coverage::get_percentage() { local total_executable=0 local total_hit=0 @@ -617,9 +661,15 @@ function bashunit::coverage::report_html() { total_pct=$((total_hit * 100 / total_executable)) fi + # Get test results + local tests_passed=$(bashunit::state::get_tests_passed) + local tests_failed=$(bashunit::state::get_tests_failed) + local tests_total=$((tests_passed + tests_failed)) + # Generate index.html bashunit::coverage::generate_index_html \ - "$output_dir/index.html" "$total_hit" "$total_executable" "$total_pct" "${file_data[@]}" + "$output_dir/index.html" "$total_hit" "$total_executable" "$total_pct" \ + "$tests_total" "$tests_passed" "$tests_failed" "${file_data[@]}" echo "Coverage HTML report written to: $output_dir/index.html" } @@ -629,16 +679,18 @@ function bashunit::coverage::generate_index_html() { local total_hit="$2" local total_executable="$3" local total_pct="$4" - shift 4 + local tests_total="$5" + local tests_passed="$6" + local tests_failed="$7" + shift 7 local file_data=("$@") - # Determine total color class - local total_class="low" - if [[ $total_pct -ge ${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80} ]]; then - total_class="high" - elif [[ $total_pct -ge ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50} ]]; then - total_class="medium" - fi + # Calculate uncovered lines and file count + local total_uncovered=$((total_executable - total_hit)) + local file_count=${#file_data[@]} + + # Calculate gauge stroke offset (440 is full circle circumference) + local gauge_offset=$((440 - (440 * total_pct / 100))) { cat << 'EOF' @@ -647,86 +699,258 @@ function bashunit::coverage::generate_index_html() { - Coverage Report + Coverage Report | bashunit -
-

Coverage Report

-
-
Total Coverage
+
+
+
+ EOF - echo "
${total_pct}%
" - echo "
${total_hit} of ${total_executable} lines covered
" + echo "
v${BASHUNIT_VERSION:-0.0.0}
" cat << 'EOF' +
+

Code Coverage Report

+

Comprehensive line-by-line coverage analysis for your bash scripts

- - - - - - - - - - + +
+
+
+ + + + + + + + +EOF + echo " " + cat << 'EOF' + +
+EOF + echo "
${total_pct}%
" + cat << 'EOF' +
Coverage
+
+
+
+

Overall Code Coverage

+EOF + echo "

${total_hit} of ${total_executable} executable lines covered across ${file_count} files.

" + cat << 'EOF' + +
+
+
Coverage Metrics
+
+
+ + Total: +EOF + echo " ${total_executable} lines" + cat << 'EOF' +
+
+ + Covered: +EOF + echo " ${total_hit} lines" + cat << 'EOF' +
+
+ + Uncovered: +EOF + echo " ${total_uncovered} lines" + cat << 'EOF' +
+
+
+
+
Test Results
+
+
+ + Files: +EOF + echo " ${file_count}" + cat << 'EOF' +
+
+ + Tests: +EOF + echo " ${tests_total} total" + cat << 'EOF' +
+
+ + Passed: +EOF + echo " ${tests_passed}" + cat << 'EOF' +
+
+ + Failed: +EOF + echo " ${tests_failed}" + cat << 'EOF' +
+
+
+
+
+
+
+
+

File Coverage Details

+
+
+ +EOF + echo " ≥${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High" + cat << 'EOF' +
+
+ +EOF + echo " ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium" + cat << 'EOF' +
+
+ +EOF + echo " <${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low" + cat << 'EOF' +
+
+
+
+
FileLinesCoverage
+ + + + + + + + EOF for data in "${file_data[@]}"; do @@ -739,22 +963,45 @@ EOF class="medium" fi - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " + echo " " + echo " " + echo " " + echo " " + echo " " done cat << 'EOF' - -
FileLinesCoverage
$display_file${hit}/${executable}${pct}%
" - echo "
" + echo "
" + echo " $(basename "$display_file")" + echo "
./${display_file}
" + echo "
" + echo "
" + echo "
" + echo "
${hit}
" + echo "
of ${executable} lines
" + echo "
" + echo "
" + echo "
" + echo "
" + echo "
" + echo "
" + echo " ${pct}%" + echo "
" + echo "
- + + +
+ EOF @@ -770,6 +1017,7 @@ function bashunit::coverage::generate_file_html() { executable=$(bashunit::coverage::get_executable_lines "$file") local hit hit=$(bashunit::coverage::get_hit_lines "$file") + local uncovered=$((executable - hit)) local pct=0 if [[ $executable -gt 0 ]]; then @@ -790,6 +1038,31 @@ function bashunit::coverage::generate_file_html() { hits_by_line[_ln]=$_cnt done < <(bashunit::coverage::get_all_line_hits "$file") + # Pre-load test hits data into indexed array (for tooltips) + # Index: line number, Value: newline-separated list of "test_file:test_function" + # Using indexed array for Bash 3.2 compatibility (no associative arrays) + local -a tests_by_line=() + local _line_and_test + while IFS= read -r _line_and_test; do + [[ -z "$_line_and_test" ]] && continue + local _tln="${_line_and_test%%|*}" + local _tinfo="${_line_and_test#*|}" + if [[ -n "${tests_by_line[_tln]:-}" ]]; then + # Append only if not already present (avoid duplicates) + # Use newline boundaries to prevent false positives (e.g., test_foo matching test_foo_bar) + if [[ $'\n'"${tests_by_line[_tln]}"$'\n' != *$'\n'"$_tinfo"$'\n'* ]]; then + tests_by_line[_tln]="${tests_by_line[_tln]}"$'\n'"${_tinfo}" + fi + else + tests_by_line[_tln]="$_tinfo" + fi + done < <(bashunit::coverage::get_all_line_tests "$file") + + # Count total lines and functions + local total_lines + total_lines=$(wc -l < "$file" | tr -d ' ') + local non_executable=$((total_lines - executable)) + { cat << 'EOF' @@ -798,92 +1071,178 @@ function bashunit::coverage::generate_file_html() { EOF - echo " Coverage: $display_file" + echo " $(basename "$display_file") | Coverage Report" cat << 'EOF' -
- +
+
+ +
+
+EOF + echo " ${pct}%" + cat << 'EOF' + Coverage +
+
+EOF + echo " ${hit}/${executable}" + cat << 'EOF' + Lines +
+
+
+
+
+
+
+
+ Line Coverage Progress +EOF + echo " ${pct}%" + cat << 'EOF' +
+
+EOF + echo "
" + cat << 'EOF' +
+
+
+
+ +EOF + echo " ${hit} lines covered" + cat << 'EOF' +
+
+ +EOF + echo " ${uncovered} lines uncovered" + cat << 'EOF' +
+
+ +EOF + echo " ${non_executable} non-executable" + cat << 'EOF' +
+
+
+
+
+
+
+EOF + echo " ./${display_file}" + echo "
" + echo " ${total_lines} total lines" + echo "
" + cat << 'EOF' +
+
+ EOF - echo "

$display_file

" - echo "
" - echo "
${pct}%
" - echo "
${hit} of ${executable} lines covered
" - echo "
" - echo "
" - echo "
" local lineno=0 while IFS= read -r line || [[ -n "$line" ]]; do @@ -898,29 +1257,49 @@ EOF if bashunit::coverage::is_executable_line "$line" "$lineno"; then # O(1) lookup from pre-loaded array local hits=${hits_by_line[$lineno]:-0} - hits_display="$hits" if [[ $hits -gt 0 ]]; then row_class="covered" + + # Check if we have test info for this line + local test_info="${tests_by_line[$lineno]:-}" + if [[ -n "$test_info" ]]; then + # Build tooltip with test information + local tooltip_html="
Tests hitting this line
    " + while IFS=':' read -r test_file test_fn; do + [[ -z "$test_file" ]] && continue + local short_file + short_file=$(basename "$test_file") + tooltip_html+="
  • ${short_file}:${test_fn}
  • " + done <<< "$test_info" + tooltip_html+="
" + hits_display="${hits}×${tooltip_html}" + else + hits_display="${hits}×" + fi else row_class="uncovered" + hits_display="${hits}×" fi fi - echo " " - echo " " - echo " " - echo " " - echo " " + echo " " + echo " " + echo " " + echo " " + echo " " done < "$file" cat << 'EOF' -
$lineno$hits_display$escaped_line
$lineno$hits_display$escaped_line
-
-
+ EOF diff --git a/src/runner.sh b/src/runner.sh index 74a04065..72241452 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -390,6 +390,11 @@ function bashunit::runner::run_test() { # create temporary files scoped per test run. This prevents # race conditions when running tests in parallel. export BASHUNIT_CURRENT_TEST_ID="$(bashunit::helper::generate_id "$fn_name")" + # Export current test file and function for coverage tracking (only when coverage enabled) + if bashunit::env::is_coverage_enabled; then + export _BASHUNIT_COVERAGE_CURRENT_TEST_FILE="$test_file" + export _BASHUNIT_COVERAGE_CURRENT_TEST_FN="$fn_name" + fi bashunit::state::reset_test_title