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
-
+
+
+
+