diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a53623..8cc79a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ - `--show-output` displays captured test output on assertion failures (#637) - npm registry distribution: `npm install -g bashunit` (#244) - `bashunit::env::supports_color` and `bashunit::io::clear_screen` helpers (#247) +- LCOV reports now include `FN`, `FNDA`, `FNF` and `FNH` function records, consumed by `genhtml`, Codecov and Coveralls +- LCOV reports now include `BRDA`, `BRF` and `BRH` branch records for `if`/`elif`/`else` chains and `case` patterns (see `adrs/adr-007-branch-coverage-mvp.md`) +- `BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true` adds a per-function coverage block to the text report +- `BASHUNIT_COVERAGE_SHOW_UNCOVERED=true` adds an "Uncovered Lines" block to the text report, with consecutive line numbers compressed into ranges ### Changed - Docs moved into their own npm workspace under `docs/` (use `cd docs && npm ci` or `make docs/install`) diff --git a/adrs/adr-007-branch-coverage-mvp.md b/adrs/adr-007-branch-coverage-mvp.md new file mode 100644 index 00000000..d463162c --- /dev/null +++ b/adrs/adr-007-branch-coverage-mvp.md @@ -0,0 +1,90 @@ +# Branch Coverage MVP via Static Branch-Point Detection + +* Status: accepted +* Date: 2026-05-04 + +## Context and Problem Statement + +Coverage today reports line-level execution only. Standard tooling (genhtml, Codecov, Coveralls) consumes branch records via the LCOV `BRDA`/`BRF`/`BRH` fields, which let reviewers see whether `else`/`elif` arms and individual `case` patterns were exercised. Adding true branch coverage to a Bash framework is non-trivial because: + +1. Bash exposes no native instrumentation comparable to gcov branch counters. +2. The DEBUG trap fires on commands, not on branch decisions. +3. `BASH_COMMAND` reflects the *next* command, not the boolean outcome of a conditional. + +We need a path that yields useful, mostly-correct branch metrics in LCOV reports without breaking Bash 3.0+ compatibility or the cost profile of the existing line tracker. + +## Decision Drivers + +* Bash 3.0+ compatibility (no associative arrays, no `[[`, no Bash 4-only features). +* Reuse existing line-hit data; do not double the runtime cost of coverage. +* LCOV output must be consumable by genhtml, Codecov and Coveralls without custom processing. +* Implementation must fit in `src/coverage.sh` and remain testable with the existing unit-test patterns. +* Behavior must be predictable enough to pin in tests; "best-effort heuristic" outputs are not acceptable. + +## Considered Options + +1. **Static branch-point detection plus line-hit inference** — parse the source file for branch-introducing constructs (`if`/`elif`/`else`, `case` patterns), compute the line range owned by each outcome, then mark the outcome as "taken" iff any line inside its range was hit. +2. **Runtime decision tracing via `BASH_COMMAND`** — record the actual command being executed in the DEBUG trap and reconstruct decisions taken (`if X` followed by execution of either then-block or else-block). +3. **Patch-based instrumentation** — preprocess source files to insert hit recorders inside each branch arm, run tests against the instrumented copy, post-process the data file. + +## Decision Outcome + +Chosen option: **Option 1 (static branch-point detection plus line-hit inference)**. + +It reuses the existing line-hit data file with no DEBUG-trap changes. Bash 3.0+ compatibility is preserved because the parser is a single pass over the source with brace counting, identical in shape to the existing `extract_functions` walker. The output maps cleanly to LCOV `BRDA` records, and the contract ("an arm is taken iff any executable line inside it was hit") is precise enough to write unit tests against. + +### Positive Consequences + +* Zero runtime cost beyond the existing line tracker. Branch records are computed during report generation, not during test execution. +* Reuses `is_executable_line` and `get_all_line_hits`, which already tolerate Bash 3.0 limitations. +* LCOV output remains a single file, consumed unchanged by downstream tools. + +### Negative Consequences + +* Branch detection is line-presence based, not outcome based. A `then` arm whose only statement is a comment-line will register as `not taken` even if the conditional fired (because there are no executable lines inside). This is documented as a known limitation. +* Implicit `else` (when an `if/elif` chain has no explicit `else`) is reported only when at least one explicit arm exists; the synthetic "fall-through" outcome is omitted from this MVP and may be added in a follow-up. +* Compound conditionals (`if A && B`) are reported as a single binary decision, not per sub-expression. + +## Pros and Cons of the Options + +### Option 1: Static + line-hit inference (chosen) + +* Good, because reuses existing data and code paths. +* Good, because matches the implementation pattern of `extract_functions` already shipping in the codebase. +* Good, because output is deterministic and easy to test. +* Bad, because cannot distinguish "arm executed but produced no executable lines" from "arm not executed". + +### Option 2: Runtime DEBUG-trap decision tracing + +* Good, because reflects actual runtime behavior. +* Bad, because `BASH_COMMAND` semantics across Bash 3.x and 5.x diverge for `((...))`, `[[...]]` and pipelines, requiring per-version logic. +* Bad, because increases per-line overhead; the existing tracker already has measurable cost. +* Bad, because subshell context loss (already documented for line coverage) extends to branches taken inside `$(...)`. + +### Option 3: Source-rewrite instrumentation + +* Good, because most accurate signal possible. +* Bad, because requires either running tests against a rewritten source tree or hooking `source` to redirect to instrumented copies — both invasive and brittle. +* Bad, because debugging stack traces and line numbers no longer match the user's source. +* Bad, because doubles the code surface and breaks the "DEBUG-trap only" simplicity model. + +## Scope of MVP + +Included: + +* `if`/`elif`/`else` chains: each arm is one outcome. +* `case` statements: each pattern is one outcome. +* LCOV `BRDA:,,,` lines. +* `BRF:` and `BRH:` per file. + +Deferred (potential follow-ups): + +* Synthetic "implicit-else" outcomes for `if/elif` chains without an explicit `else`. +* Per-sub-expression decisions inside `if A && B`. +* `&&` / `||` short-circuit branches outside `if`. +* Loop-entry decisions (`while`/`until`). + +## Links + +* Builds on the function extractor introduced in `src/coverage.sh` (see `bashunit::coverage::extract_functions`). +* LCOV format reference: diff --git a/docs/coverage.md b/docs/coverage.md index 909f3193..27f9bdd4 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -106,6 +106,10 @@ BASHUNIT_COVERAGE_MIN=80 # Color thresholds for console output BASHUNIT_COVERAGE_THRESHOLD_LOW=50 # Red below this BASHUNIT_COVERAGE_THRESHOLD_HIGH=80 # Green above this, yellow between + +# Optional text-report blocks (off by default, opt-in for verbose runs) +BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true # Print per-function coverage +BASHUNIT_COVERAGE_SHOW_UNCOVERED=true # Print missed line ranges per file ``` ## Examples @@ -274,6 +278,13 @@ end_of_record |-------|-------------|---------| | `TN:` | Test Name (usually empty) | `TN:` | | `SF:` | Source File path | `SF:/home/user/project/src/math.sh` | +| `FN:` | Function: `start_line,name` | `FN:5,multiply` | +| `FNDA:` | Function call data: `count,name` (1 if any line in body was hit, else 0) | `FNDA:1,add` | +| `FNF:` | Functions Found | `FNF:2` | +| `FNH:` | Functions Hit | `FNH:1` | +| `BRDA:` | Branch data: `decision_line,block,arm,taken` | `BRDA:12,0,1,1` | +| `BRF:` | Branches Found | `BRF:6` | +| `BRH:` | Branches Hit | `BRH:4` | | `DA:` | Line Data: `line_number,hit_count` | `DA:15,3` (line 15 hit 3 times) | | `LF:` | Lines Found (total executable lines) | `LF:25` | | `LH:` | Lines Hit (lines with hits > 0) | `LH:20` | @@ -347,6 +358,131 @@ These lines are not counted toward coverage: - Control flow keywords (`then`, `else`, `fi`, `do`, `done`, `esac`, `in`) - Case statement patterns (`--option)`, `*)`) and terminators (`;;`, `;&`, `;;&`) +## Branch Coverage + +Beyond line and function coverage, bashunit emits **branch coverage** records in the LCOV report so reviewers can see whether each `else`/`elif` arm and each `case` pattern was exercised. Branch records are produced automatically; no extra flags are needed. + +### What Counts as a Branch + +| Construct | Arms | +|-----------|------| +| `if X; then ... fi` | 1 (the `then` body) | +| `if X; then ... else ... fi` | 2 (`then` + `else`) | +| `if X; then ... elif Y; then ... else ... fi` | 3 (one per arm) | +| `case X in a) ... ;; b) ... ;; *) ... ;; esac` | one per pattern | + +An arm is reported as **taken** iff at least one executable line inside its range was hit by tests. + +### Verbose Output Helpers + +Two opt-in environment variables enrich the text report when investigating coverage gaps: + +::: code-group +```bash [Per-function block] +BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true bashunit tests/ --coverage +``` +```bash [Uncovered lines block] +BASHUNIT_COVERAGE_SHOW_UNCOVERED=true bashunit tests/ --coverage +``` +```bash [Both] +BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true \ +BASHUNIT_COVERAGE_SHOW_UNCOVERED=true \ + bashunit tests/ --coverage +``` +::: + +The default text report stays compact; opt in only when triaging. + +### Worked Example + +Given `src/route.sh`: + +```bash +#!/usr/bin/env bash +function route() { + if [ "$1" = "GET" ]; then + echo "fetch" + elif [ "$1" = "POST" ]; then + echo "create" + else + echo "405" + fi +} +``` + +If tests only call `route GET`, the LCOV record looks like: + +``` +TN: +SF:/path/to/src/route.sh +FN:2,route +FNDA:1,route +FNF:1 +FNH:1 +BRDA:3,0,0,1 +BRDA:3,0,1,0 +BRDA:3,0,2,0 +BRF:3 +BRH:1 +DA:3,1 +DA:4,1 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +LF:6 +LH:2 +end_of_record +``` + +**Reading the branch records:** +- `BRDA:3,0,0,1`: decision on line 3, block 0, arm 0 (`then`/GET), taken. +- `BRDA:3,0,1,0`: same decision, arm 1 (`elif`/POST), not taken. +- `BRDA:3,0,2,0`: same decision, arm 2 (`else`/405), not taken. +- `BRF:3` `BRH:1`: 3 branches found, 1 taken. + +### Visualizing with genhtml + +LCOV's `genhtml` renders branch coverage alongside line and function coverage: + +::: code-group +```bash [Generate] +bashunit tests/ --coverage +genhtml --branch-coverage coverage/lcov.info -o coverage/html +``` +::: + +The resulting site shows a red/green diamond next to each branch decision, mirroring `gcov`'s C/C++ output. + +### CI Integration + +Codecov and Coveralls pick up the new records without configuration. To require branch coverage in PR gates: + +::: code-group +```yaml [Codecov] +coverage: + status: + project: + default: + target: 80% + patch: + default: + target: 80% + threshold: 0% + flags: + - branch +``` +::: + +### Limitations + +- An arm whose body has no executable lines (only comments or braces) registers as not-taken even when the conditional fired. +- Implicit `else` (an `if`/`elif` chain without an explicit `else`) reports only the explicit arms; the synthetic fall-through outcome is omitted. +- Compound conditionals (`if A && B`) are reported as a single binary decision, not per sub-expression. +- `&&`/`||` short-circuit branches outside `if` and loop-entry decisions (`while`/`until`) are not tracked. + +See `adrs/adr-007-branch-coverage-mvp.md` for the design rationale and the rejected alternatives. + ## Limitations ### External Commands @@ -355,4 +491,12 @@ Coverage only tracks Bash code. External commands (like `grep`, `sed`, etc.) are ### Subshell Behavior -Due to Bash's process model, some subshell contexts may not have full coverage tracking. The DEBUG trap is inherited into subshells, but complex nested scenarios may have edge cases. +Due to Bash's process model, hits produced inside a subshell are written to the subshell's in-memory buffer, which is discarded when the subshell exits. The pinned behavior is: + +- `$( ... )` command substitution: the outer line is recorded; commands inside the substitution are not. +- `( ... )` explicit subshells: the same applies; only the outer line is tracked. +- Pipelines (`a | b`): each stage is recorded as a single hit on its source line. +- Process substitution `< <( ... )`: the consumer side is fully tracked; producer lines are not. +- Functions invoked from `$( ... )`: the call site and surrounding lines are hit, but the function body lines are lost when called inside a subshell. + +These contracts are pinned by `tests/unit/coverage_subshell_test.sh`. diff --git a/src/coverage.sh b/src/coverage.sh index d3786007..ba228fa9 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -776,47 +776,224 @@ function bashunit::coverage::extract_functions() { fi } -# Calculate coverage for a specific function in a file -# Returns: hit_lines:executable_lines:percentage -function bashunit::coverage::get_function_coverage() { - local file="$1" - local fn_start="$2" - local fn_end="$3" - shift 3 +# Append "start:end" to a comma-separated arms string. Result is +# returned via the global _BASHUNIT_BRANCH_ARMS_OUT to avoid the cost +# of a subshell on a hot per-line path. Bash 3.0 cannot pass arrays +# (or namerefs) by reference, so a single output slot is the cheapest +# portable option. +_BASHUNIT_BRANCH_ARMS_OUT="" +function bashunit::coverage::_append_arm() { + local existing="$1" arm_start="$2" arm_end="$3" + if [ -z "$existing" ]; then + _BASHUNIT_BRANCH_ARMS_OUT="${arm_start}:${arm_end}" + else + _BASHUNIT_BRANCH_ARMS_OUT="${existing},${arm_start}:${arm_end}" + fi +} - # Accept hits_by_line array as nameref (Bash 4.3+) or fall back to counting - local -n _hits_ref=$1 2>/dev/null || true +# Detect whether a trimmed line is a case-pattern opener (ends with +# `)` optionally followed by whitespace and a comment). Avoids +# matching mid-line uses such as `cmd $(other)`. +function bashunit::coverage::_is_case_pattern_line() { + local trimmed="$1" + case "$trimmed" in + *')'*) ;; + *) return 1 ;; + esac - local executable=0 - local hit=0 - local lineno=0 + local before_paren="${trimmed%%')'*}" + local after="${trimmed#"$before_paren"}" + after="${after#)}" + after="${after#"${after%%[![:space:]]*}"}" + case "$after" in + '' | '#'*) return 0 ;; + esac + return 1 +} - # Pre-load file lines into indexed array (avoids sed per line) - local -a fn_lines=() - local _fli=0 _fl - while IFS= read -r _fl || [ -n "$_fl" ]; do - fn_lines[_fli]="$_fl" - ((++_fli)) - done <"$file" +# Extract branch points from a Bash file. +# Output format: ||:[,:]... +# kind ∈ {if, case} +# Scope: if/elif/else chains and case patterns. See adrs/adr-007-branch-coverage-mvp.md. +# The handlers below operate on the per-construct state arrays that +# extract_branches keeps as locals. Bash 3.0 has dynamic scoping for +# `local` vars, so the helpers see and mutate the caller's state +# without needing namerefs (which would require Bash 4.3+). + +function bashunit::coverage::_branch_push_if() { + local lineno=$1 + if_decision_line[if_depth]=$lineno + if_arms[if_depth]="" + if_arm_start[if_depth]=$((lineno + 1)) + if_depth=$((if_depth + 1)) +} - for ((lineno = fn_start; lineno <= fn_end; lineno++)); do - local line_content="${fn_lines[$((lineno - 1))]:-}" +function bashunit::coverage::_branch_close_if_arm() { + local lineno=$1 idx=$((if_depth - 1)) + bashunit::coverage::_append_arm \ + "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" + if_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + if_arm_start[idx]=$((lineno + 1)) +} - if bashunit::coverage::is_executable_line "$line_content" "$lineno"; then - ((++executable)) - local line_hits=${_hits_ref[$lineno]:-0} - if [ "$line_hits" -gt 0 ]; then - ((++hit)) - fi +function bashunit::coverage::_branch_emit_if() { + local lineno=$1 idx=$((if_depth - 1)) + bashunit::coverage::_append_arm \ + "${if_arms[$idx]}" "${if_arm_start[$idx]}" "$((lineno - 1))" + echo "${if_decision_line[$idx]}|if|${_BASHUNIT_BRANCH_ARMS_OUT}" + if_depth=$idx +} + +function bashunit::coverage::_branch_push_case() { + local lineno=$1 + case_decision_line[case_depth]=$lineno + case_arms[case_depth]="" + case_arm_start[case_depth]=0 + case_in_pattern[case_depth]=0 + case_depth=$((case_depth + 1)) +} + +function bashunit::coverage::_branch_close_case_arm() { + local lineno=$1 idx=$((case_depth - 1)) + [ "${case_in_pattern[$idx]}" = "1" ] || return 0 + bashunit::coverage::_append_arm \ + "${case_arms[$idx]}" "${case_arm_start[$idx]}" "$((lineno - 1))" + case_arms[idx]="$_BASHUNIT_BRANCH_ARMS_OUT" + case_in_pattern[idx]=0 +} + +function bashunit::coverage::_branch_emit_case() { + local lineno=$1 idx=$((case_depth - 1)) + bashunit::coverage::_branch_close_case_arm "$lineno" + if [ -n "${case_arms[$idx]}" ]; then + echo "${case_decision_line[$idx]}|case|${case_arms[$idx]}" + fi + case_depth=$idx +} + +function bashunit::coverage::_branch_open_case_pattern() { + local lineno=$1 idx=$((case_depth - 1)) + case_arm_start[idx]=$((lineno + 1)) + case_in_pattern[idx]=1 +} + +function bashunit::coverage::extract_branches() { + local file="$1" + + local -a lines=() + local _i=0 _l + while IFS= read -r _l || [ -n "$_l" ]; do + lines[_i]="$_l" + ((++_i)) + done <"$file" + local total_lines=$_i + + # State arrays — read and mutated by the _branch_* helpers via Bash's + # dynamic scoping. Each array is keyed by depth so nested constructs + # work without associative arrays. + local -a if_decision_line=() if_arms=() if_arm_start=() + local if_depth=0 + local -a case_decision_line=() case_arms=() case_arm_start=() case_in_pattern=() + local case_depth=0 + + local lineno=0 line trimmed first + while [ "$lineno" -lt "$total_lines" ]; do + line="${lines[$lineno]}" + lineno=$((lineno + 1)) + + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in '' | '#'*) continue ;; esac + first="${trimmed%%[[:space:]\;]*}" + + # Reserved-word patterns single-quoted to dodge `case ... esac` + # parser confusion. + case "$first" in + 'if') bashunit::coverage::_branch_push_if "$lineno" ;; + 'elif' | 'else') + [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_close_if_arm "$lineno" + ;; + 'fi') + [ "$if_depth" -gt 0 ] && bashunit::coverage::_branch_emit_if "$lineno" + ;; + 'case') bashunit::coverage::_branch_push_case "$lineno" ;; + 'esac') + [ "$case_depth" -gt 0 ] && bashunit::coverage::_branch_emit_case "$lineno" + ;; + *) + [ "$case_depth" -eq 0 ] && continue + case "$trimmed" in + ';;&'* | ';;'* | ';&'*) + bashunit::coverage::_branch_close_case_arm "$lineno" + ;; + *) + if bashunit::coverage::_is_case_pattern_line "$trimmed"; then + bashunit::coverage::_branch_open_case_pattern "$lineno" + fi + ;; + esac + ;; + esac + done +} + +# Sets _BASHUNIT_ARM_TAKEN_OUT to 1 iff any executable line in +# [arm_start..arm_end] has a recorded hit, else 0. Caller must have +# populated the hits_by_line and src_lines arrays in scope; Bash 3.0 +# cannot pass arrays into a function. Result is returned via the +# global to avoid a per-arm subshell. +_BASHUNIT_ARM_TAKEN_OUT=0 +function bashunit::coverage::_arm_taken() { + local arm_start="$1" arm_end="$2" ln + for ((ln = arm_start; ln <= arm_end; ln++)); do + bashunit::coverage::is_executable_line \ + "${src_lines[$((ln - 1))]:-}" "$ln" || continue + if [ "${hits_by_line[$ln]:-0}" -gt 0 ]; then + _BASHUNIT_ARM_TAKEN_OUT=1 + return fi done + _BASHUNIT_ARM_TAKEN_OUT=0 +} - local pct=0 - if [ "$executable" -gt 0 ]; then - pct=$((hit * 100 / executable)) - fi +# Compute branch hit data for a file. +# Output format: ||| +# block = sequential id per decision (0..N-1), branch_index = arm index (0..M-1). +# An arm is "taken" iff at least one executable line inside its range +# has a recorded hit. taken_count is 0 or 1 — MVP does not preserve +# per-arm hit counts. +function bashunit::coverage::compute_branch_hits() { + local file="$1" + + local -a hits_by_line=() + local _hl_ln _hl_cnt + while IFS=: read -r _hl_ln _hl_cnt; do + [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt + done < <(bashunit::coverage::get_all_line_hits "$file") - echo "${hit}:${executable}:${pct}" + local -a src_lines=() + local _sli=0 _sl + while IFS= read -r _sl || [ -n "$_sl" ]; do + src_lines[_sli]="$_sl" + ((++_sli)) + done <"$file" + + local block=0 decision_line _kind arms branch_entry + local -a arm_specs=() + local arm arm_index + while IFS= read -r branch_entry; do + [ -z "$branch_entry" ] && continue + IFS='|' read -r decision_line _kind arms <<<"$branch_entry" + + arm_index=0 + IFS=',' read -ra arm_specs <<<"$arms" + for arm in "${arm_specs[@]}"; do + bashunit::coverage::_arm_taken "${arm%%:*}" "${arm##*:}" + echo "${decision_line}|${block}|${arm_index}|${_BASHUNIT_ARM_TAKEN_OUT}" + arm_index=$((arm_index + 1)) + done + + block=$((block + 1)) + done < <(bashunit::coverage::extract_branches "$file") } function bashunit::coverage::get_percentage() { @@ -903,6 +1080,16 @@ function bashunit::coverage::report_text() { printf "%sTotal: %d/%d (%d%%)%s\n" \ "$color" "$total_hit" "$total_executable" "$total_pct" "$reset" + # Optional per-function summary (gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS) + if [ "${BASHUNIT_COVERAGE_SHOW_FUNCTIONS:-false}" = "true" ]; then + bashunit::coverage::report_text_functions + fi + + # Optional uncovered hotspots (gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED) + if [ "${BASHUNIT_COVERAGE_SHOW_UNCOVERED:-false}" = "true" ]; then + bashunit::coverage::report_text_uncovered + fi + # Show report location if generated if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then echo "" @@ -910,6 +1097,144 @@ function bashunit::coverage::report_text() { fi } +# Compress a sorted list of integers into a comma-separated range +# string (e.g. "3 4 5 7 9 10" -> "3-5,7,9-10"). Result on +# _BASHUNIT_RANGES_OUT to avoid a subshell on each call. +_BASHUNIT_RANGES_OUT="" +function bashunit::coverage::_compress_ranges() { + local out="" start="" end="" n + for n in "$@"; do + if [ -z "$start" ]; then + start="$n" + end="$n" + elif [ "$n" -eq $((end + 1)) ]; then + end="$n" + else + if [ "$start" = "$end" ]; then + out="${out}${start}," + else + out="${out}${start}-${end}," + fi + start="$n" + end="$n" + fi + done + if [ -n "$start" ]; then + if [ "$start" = "$end" ]; then + out="${out}${start}" + else + out="${out}${start}-${end}" + fi + fi + _BASHUNIT_RANGES_OUT="${out%,}" +} + +# List executable lines that were never hit, grouped by file. +# Gated on BASHUNIT_COVERAGE_SHOW_UNCOVERED=true. Output is suppressed +# when no uncovered lines exist so a fully-covered run stays quiet. +function bashunit::coverage::report_text_uncovered() { + local file + local printed_header=false + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + + local -a hits_by_line=() + local _hl_ln _hl_cnt + while IFS=: read -r _hl_ln _hl_cnt; do + [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt + done < <(bashunit::coverage::get_all_line_hits "$file") + + local -a uncovered_lines=() + local _ucount=0 + local lineno=0 line + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + bashunit::coverage::is_executable_line "$line" "$lineno" || continue + local lh="${hits_by_line[$lineno]:-0}" + if [ "$lh" -eq 0 ]; then + uncovered_lines[_ucount]="$lineno" + _ucount=$((_ucount + 1)) + fi + done <"$file" + + [ "$_ucount" -eq 0 ] && continue + + if [ "$printed_header" != "true" ]; then + echo "" + echo "Uncovered Lines" + echo "---------------" + printed_header=true + fi + + local display_file="${file#"$(pwd)"/}" + local color="$_BASHUNIT_COLOR_FAILED" reset="$_BASHUNIT_COLOR_DEFAULT" + local out + bashunit::coverage::_compress_ranges "${uncovered_lines[@]}" + out="$_BASHUNIT_RANGES_OUT" + + printf "%s%s:%s%s\n" "$color" "$display_file" "$out" "$reset" + done < <(bashunit::coverage::get_tracked_files) +} + +# Per-function coverage summary printed after the file table. +# Gated on BASHUNIT_COVERAGE_SHOW_FUNCTIONS=true to keep default output compact. +function bashunit::coverage::report_text_functions() { + local file + local printed_header=false + while IFS= read -r file; do + { [ -z "$file" ] || [ ! -f "$file" ]; } && continue + + local functions_data + functions_data=$(bashunit::coverage::extract_functions "$file") + [ -z "$functions_data" ] && continue + + local -a hits_by_line=() + local _hl_ln _hl_cnt + while IFS=: read -r _hl_ln _hl_cnt; do + [ -n "$_hl_ln" ] && hits_by_line[_hl_ln]=$_hl_cnt + done < <(bashunit::coverage::get_all_line_hits "$file") + + local -a file_lines=() + local _fli=0 _fl + while IFS= read -r _fl || [ -n "$_fl" ]; do + file_lines[_fli]="$_fl" + ((++_fli)) + done <"$file" + + local display_file="${file#"$(pwd)"/}" + + if [ "$printed_header" != "true" ]; then + echo "" + echo "Functions" + echo "---------" + printed_header=true + fi + echo "${display_file}" + + local fn_name fn_start fn_end ln fn_executable fn_hit + local fn_pct fn_class color reset="$_BASHUNIT_COLOR_DEFAULT" + while IFS='|' read -r fn_name fn_start fn_end; do + [ -z "$fn_name" ] && continue + + fn_executable=0 + fn_hit=0 + for ((ln = fn_start; ln <= fn_end; ln++)); do + bashunit::coverage::is_executable_line \ + "${file_lines[$((ln - 1))]:-}" "$ln" || continue + fn_executable=$((fn_executable + 1)) + [ "${hits_by_line[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1)) + done + + fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable") + fn_class=$(bashunit::coverage::get_coverage_class "$fn_pct") + color=$(bashunit::coverage::get_color_for_class "$fn_class") + + printf " %s%-38s %3d/%3d lines (%3d%%)%s\n" \ + "$color" "$fn_name" "$fn_hit" "$fn_executable" "$fn_pct" "$reset" + done <<<"$functions_data" + done < <(bashunit::coverage::get_tracked_files) +} + function bashunit::coverage::report_lcov() { local output_file="${1:-$BASHUNIT_COVERAGE_REPORT}" @@ -935,6 +1260,47 @@ function bashunit::coverage::report_lcov() { [ -n "$hit_lineno" ] && hits_by_line[hit_lineno]=$hit_count done < <(bashunit::coverage::get_all_line_hits "$file") + # Function records (FN/FNDA/FNF/FNH). Emit FN lines as we walk + # and buffer the matching FNDA lines for emission after, per + # LCOV convention. + local fn_total=0 fn_hit=0 fn_name fn_start fn_end fln any_hit + local -a fn_dn_records=() + local _fdi=0 + while IFS='|' read -r fn_name fn_start fn_end; do + [ -z "$fn_name" ] && continue + echo "FN:${fn_start},${fn_name}" + fn_total=$((fn_total + 1)) + + any_hit=0 + for ((fln = fn_start; fln <= fn_end; fln++)); do + if [ "${hits_by_line[$fln]:-0}" -gt 0 ]; then + any_hit=1 + break + fi + done + fn_dn_records[_fdi]="FNDA:${any_hit},${fn_name}" + _fdi=$((_fdi + 1)) + [ "$any_hit" -eq 1 ] && fn_hit=$((fn_hit + 1)) + done < <(bashunit::coverage::extract_functions "$file") + + local fda + for fda in ${fn_dn_records[@]+"${fn_dn_records[@]}"}; do + echo "$fda" + done + echo "FNF:$fn_total" + echo "FNH:$fn_hit" + + # Branch records (BRDA/BRF/BRH) + local br_total=0 br_hit=0 br_line br_block br_idx br_taken + while IFS='|' read -r br_line br_block br_idx br_taken; do + [ -z "$br_line" ] && continue + echo "BRDA:${br_line},${br_block},${br_idx},${br_taken}" + br_total=$((br_total + 1)) + [ "$br_taken" -gt 0 ] && br_hit=$((br_hit + 1)) + done < <(bashunit::coverage::compute_branch_hits "$file") + echo "BRF:$br_total" + echo "BRH:$br_hit" + local lineno=0 executable=0 hit=0 line line_hits local -a lcov_lines=() local _lli=0 _ll diff --git a/tests/unit/coverage_branches_test.sh b/tests/unit/coverage_branches_test.sh new file mode 100644 index 00000000..895cf265 --- /dev/null +++ b/tests/unit/coverage_branches_test.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Tests for the branch-point extractor and branch-hit computation. +# See adrs/adr-007-branch-coverage-mvp.md for the design. + +_ORIG_COVERAGE_DATA_FILE="" +_ORIG_COVERAGE_TRACKED_FILES="" +_ORIG_COVERAGE_TRACKED_CACHE_FILE="" +_ORIG_COVERAGE_TEST_HITS_FILE="" +_ORIG_COVERAGE="" + +function set_up() { + _ORIG_COVERAGE_DATA_FILE="$_BASHUNIT_COVERAGE_DATA_FILE" + _ORIG_COVERAGE_TRACKED_FILES="$_BASHUNIT_COVERAGE_TRACKED_FILES" + _ORIG_COVERAGE_TRACKED_CACHE_FILE="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + _ORIG_COVERAGE_TEST_HITS_FILE="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + _ORIG_COVERAGE="${BASHUNIT_COVERAGE:-}" + + _BASHUNIT_COVERAGE_DATA_FILE="" + _BASHUNIT_COVERAGE_TRACKED_FILES="" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="" + export BASHUNIT_COVERAGE="true" +} + +function tear_down() { + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then + local coverage_dir + coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") + rm -rf "$coverage_dir" 2>/dev/null || true + fi + + _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" + _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="$_ORIG_COVERAGE_TEST_HITS_FILE" + + if [ -n "$_ORIG_COVERAGE" ]; then + export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" + else + unset BASHUNIT_COVERAGE + fi +} + +# extract_branches output format: +# ||:[,:]... +# kind ∈ {if, case} + +function test_extract_branches_finds_simple_if_else() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "x" +else + echo "not x" +fi +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # Decision on line 2 with two arms: then (line 3) and else (line 5) + assert_contains "2|if|3:3,5:5" "$result" + + rm -f "$fixture" +} + +function test_extract_branches_finds_if_elif_else_chain() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "a" ]; then + echo "a" +elif [ "$1" = "b" ]; then + echo "b" +else + echo "other" +fi +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # Three arms: then (line 3), elif body (line 5), else (line 7) + assert_contains "2|if|3:3,5:5,7:7" "$result" + + rm -f "$fixture" +} + +function test_extract_branches_finds_case_patterns() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +case "$1" in +a) + echo "got a" + ;; +b) + echo "got b" + ;; +*) + echo "other" + ;; +esac +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # case decision on line 2, three pattern arms with bodies on 4, 7, 10 + assert_contains "2|case|4:4,7:7,10:10" "$result" + + rm -f "$fixture" +} + +function test_extract_branches_returns_nothing_for_no_branches() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +echo "no branches here" +echo "still none" +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + assert_empty "$result" + + rm -f "$fixture" +} + +function test_extract_branches_handles_if_without_else() { + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "x" +fi +EOF + + local result + result=$(bashunit::coverage::extract_branches "$fixture") + + # MVP scope: only the explicit then arm is reported. Implicit-else + # (synthetic fall-through outcome) is deferred per ADR-007. + assert_contains "2|if|3:3" "$result" + + rm -f "$fixture" +} + +function test_compute_branch_hits_marks_taken_arm() { + bashunit::coverage::init + + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "taken" +else + echo "not-taken" +fi +EOF + + echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + # Hit only the `then` arm body + echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local result + result=$(bashunit::coverage::compute_branch_hits "$fixture") + + # Format: decision_line|block|arm_index|taken_count + assert_contains "2|0|0|1" "$result" + assert_contains "2|0|1|0" "$result" + + rm -f "$fixture" +} + +function test_compute_branch_hits_marks_all_arms_zero_when_unhit() { + bashunit::coverage::init + + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "x" +else + echo "y" +fi +EOF + + echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local result + result=$(bashunit::coverage::compute_branch_hits "$fixture") + + assert_contains "2|0|0|0" "$result" + assert_contains "2|0|1|0" "$result" + + rm -f "$fixture" +} + +function test_compute_branch_hits_assigns_distinct_blocks_per_decision() { + bashunit::coverage::init + + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "first" +fi +if [ "$2" = "y" ]; then + echo "second" +fi +EOF + + echo "$fixture" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${fixture}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + echo "${fixture}:6" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local result + result=$(bashunit::coverage::compute_branch_hits "$fixture") + + # Two decisions -> two distinct block ids (0 and 1) + assert_contains "2|0|0|1" "$result" + assert_contains "5|1|0|1" "$result" + + rm -f "$fixture" +} diff --git a/tests/unit/coverage_core_test.sh b/tests/unit/coverage_core_test.sh index 2ded4a14..fdf0fc34 100644 --- a/tests/unit/coverage_core_test.sh +++ b/tests/unit/coverage_core_test.sh @@ -35,7 +35,8 @@ function set_up() { function tear_down() { # Clean up any coverage temp files created by tests - if [[ -n "$_BASHUNIT_COVERAGE_DATA_FILE" && "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]]; then + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then local coverage_dir coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") rm -rf "$coverage_dir" 2>/dev/null || true @@ -45,27 +46,27 @@ function tear_down() { _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" - if [[ -n "$_ORIG_COVERAGE" ]]; then + if [ -n "$_ORIG_COVERAGE" ]; then export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" else unset BASHUNIT_COVERAGE fi - if [[ -n "$_ORIG_COVERAGE_PATHS" ]]; then + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" else unset BASHUNIT_COVERAGE_PATHS fi - if [[ -n "$_ORIG_COVERAGE_EXCLUDE" ]]; then + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" else unset BASHUNIT_COVERAGE_EXCLUDE fi - if [[ -n "$_ORIG_COVERAGE_REPORT" ]]; then + if [ -n "$_ORIG_COVERAGE_REPORT" ]; then export BASHUNIT_COVERAGE_REPORT="$_ORIG_COVERAGE_REPORT" else unset BASHUNIT_COVERAGE_REPORT fi - if [[ -n "$_ORIG_COVERAGE_MIN" ]]; then + if [ -n "$_ORIG_COVERAGE_MIN" ]; then export BASHUNIT_COVERAGE_MIN="$_ORIG_COVERAGE_MIN" else unset BASHUNIT_COVERAGE_MIN diff --git a/tests/unit/coverage_executable_test.sh b/tests/unit/coverage_executable_test.sh index 9e257752..e87a0116 100644 --- a/tests/unit/coverage_executable_test.sh +++ b/tests/unit/coverage_executable_test.sh @@ -35,7 +35,8 @@ function set_up() { function tear_down() { # Clean up any coverage temp files created by tests - if [[ -n "$_BASHUNIT_COVERAGE_DATA_FILE" && "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]]; then + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then local coverage_dir coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") rm -rf "$coverage_dir" 2>/dev/null || true @@ -45,27 +46,27 @@ function tear_down() { _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" - if [[ -n "$_ORIG_COVERAGE" ]]; then + if [ -n "$_ORIG_COVERAGE" ]; then export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" else unset BASHUNIT_COVERAGE fi - if [[ -n "$_ORIG_COVERAGE_PATHS" ]]; then + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" else unset BASHUNIT_COVERAGE_PATHS fi - if [[ -n "$_ORIG_COVERAGE_EXCLUDE" ]]; then + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" else unset BASHUNIT_COVERAGE_EXCLUDE fi - if [[ -n "$_ORIG_COVERAGE_REPORT" ]]; then + if [ -n "$_ORIG_COVERAGE_REPORT" ]; then export BASHUNIT_COVERAGE_REPORT="$_ORIG_COVERAGE_REPORT" else unset BASHUNIT_COVERAGE_REPORT fi - if [[ -n "$_ORIG_COVERAGE_MIN" ]]; then + if [ -n "$_ORIG_COVERAGE_MIN" ]; then export BASHUNIT_COVERAGE_MIN="$_ORIG_COVERAGE_MIN" else unset BASHUNIT_COVERAGE_MIN diff --git a/tests/unit/coverage_helpers_test.sh b/tests/unit/coverage_helpers_test.sh index 15112d46..2439a82e 100644 --- a/tests/unit/coverage_helpers_test.sh +++ b/tests/unit/coverage_helpers_test.sh @@ -35,7 +35,8 @@ function set_up() { function tear_down() { # Clean up any coverage temp files created by tests - if [[ -n "$_BASHUNIT_COVERAGE_DATA_FILE" && "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]]; then + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then local coverage_dir coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") rm -rf "$coverage_dir" 2>/dev/null || true @@ -45,27 +46,27 @@ function tear_down() { _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" - if [[ -n "$_ORIG_COVERAGE" ]]; then + if [ -n "$_ORIG_COVERAGE" ]; then export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" else unset BASHUNIT_COVERAGE fi - if [[ -n "$_ORIG_COVERAGE_PATHS" ]]; then + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" else unset BASHUNIT_COVERAGE_PATHS fi - if [[ -n "$_ORIG_COVERAGE_EXCLUDE" ]]; then + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" else unset BASHUNIT_COVERAGE_EXCLUDE fi - if [[ -n "$_ORIG_COVERAGE_REPORT" ]]; then + if [ -n "$_ORIG_COVERAGE_REPORT" ]; then export BASHUNIT_COVERAGE_REPORT="$_ORIG_COVERAGE_REPORT" else unset BASHUNIT_COVERAGE_REPORT fi - if [[ -n "$_ORIG_COVERAGE_MIN" ]]; then + if [ -n "$_ORIG_COVERAGE_MIN" ]; then export BASHUNIT_COVERAGE_MIN="$_ORIG_COVERAGE_MIN" else unset BASHUNIT_COVERAGE_MIN diff --git a/tests/unit/coverage_parallel_aggregation_test.sh b/tests/unit/coverage_parallel_aggregation_test.sh new file mode 100644 index 00000000..b2306bde --- /dev/null +++ b/tests/unit/coverage_parallel_aggregation_test.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Parallel coverage aggregation tests. +# When tests run in parallel, each worker writes to a per-PID file +# alongside the canonical data file (e.g. hits.dat.12345). The +# aggregate_parallel function is responsible for merging those into the +# canonical files at end-of-run and deduplicating the tracked-files +# index. + +_ORIG_COVERAGE_DATA_FILE="" +_ORIG_COVERAGE_TRACKED_FILES="" +_ORIG_COVERAGE_TRACKED_CACHE_FILE="" +_ORIG_COVERAGE_TEST_HITS_FILE="" +_ORIG_COVERAGE="" + +function set_up() { + _ORIG_COVERAGE_DATA_FILE="$_BASHUNIT_COVERAGE_DATA_FILE" + _ORIG_COVERAGE_TRACKED_FILES="$_BASHUNIT_COVERAGE_TRACKED_FILES" + _ORIG_COVERAGE_TRACKED_CACHE_FILE="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + _ORIG_COVERAGE_TEST_HITS_FILE="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + _ORIG_COVERAGE="${BASHUNIT_COVERAGE:-}" + + _BASHUNIT_COVERAGE_DATA_FILE="" + _BASHUNIT_COVERAGE_TRACKED_FILES="" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="" + export BASHUNIT_COVERAGE="true" +} + +function tear_down() { + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then + local coverage_dir + coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") + rm -rf "$coverage_dir" 2>/dev/null || true + fi + + _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" + _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="$_ORIG_COVERAGE_TEST_HITS_FILE" + + if [ -n "$_ORIG_COVERAGE" ]; then + export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" + else + unset BASHUNIT_COVERAGE + fi +} + +function test_aggregate_parallel_merges_hits_from_per_pid_files() { + bashunit::coverage::init + + # Simulate two worker PIDs each having written their own hits file + echo "/path/to/file.sh:5" >"${_BASHUNIT_COVERAGE_DATA_FILE}.111" + printf '/path/to/file.sh:5\n/path/to/file.sh:7\n' \ + >"${_BASHUNIT_COVERAGE_DATA_FILE}.222" + + bashunit::coverage::aggregate_parallel + + local merged + merged=$(cat "$_BASHUNIT_COVERAGE_DATA_FILE") + + assert_contains "/path/to/file.sh:5" "$merged" + assert_contains "/path/to/file.sh:7" "$merged" + + # Per-PID files removed after merge + assert_file_not_exists "${_BASHUNIT_COVERAGE_DATA_FILE}.111" + assert_file_not_exists "${_BASHUNIT_COVERAGE_DATA_FILE}.222" +} + +function test_aggregate_parallel_dedupes_tracked_files() { + bashunit::coverage::init + + # Two workers tracked the same file; aggregation must keep one entry + printf '/path/to/file.sh\n/path/to/other.sh\n' \ + >"${_BASHUNIT_COVERAGE_TRACKED_FILES}.111" + printf '/path/to/file.sh\n/path/to/third.sh\n' \ + >"${_BASHUNIT_COVERAGE_TRACKED_FILES}.222" + + bashunit::coverage::aggregate_parallel + + local entries + entries=$(wc -l <"$_BASHUNIT_COVERAGE_TRACKED_FILES" | tr -d ' ') + + # Three unique paths despite duplicate file.sh entries + assert_equals "3" "$entries" +} + +function test_aggregate_parallel_merges_test_hits_files() { + bashunit::coverage::init + + echo "/src/a.sh:10|tests/x_test.sh:test_one" \ + >"${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.111" + echo "/src/a.sh:10|tests/y_test.sh:test_two" \ + >"${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.222" + + bashunit::coverage::aggregate_parallel + + local content + content=$(cat "$_BASHUNIT_COVERAGE_TEST_HITS_FILE") + + assert_contains "test_one" "$content" + assert_contains "test_two" "$content" +} + +function test_aggregate_parallel_is_a_noop_when_no_per_pid_files_exist() { + bashunit::coverage::init + + echo "/already-merged.sh:1" >"$_BASHUNIT_COVERAGE_DATA_FILE" + + bashunit::coverage::aggregate_parallel + + local content + content=$(cat "$_BASHUNIT_COVERAGE_DATA_FILE") + + assert_equals "/already-merged.sh:1" "$content" +} + +function test_aggregate_parallel_handles_empty_per_pid_file() { + bashunit::coverage::init + + : >"${_BASHUNIT_COVERAGE_DATA_FILE}.999" + + bashunit::coverage::aggregate_parallel + + # Empty file aggregated and removed without error + assert_file_not_exists "${_BASHUNIT_COVERAGE_DATA_FILE}.999" +} diff --git a/tests/unit/coverage_reporting_test.sh b/tests/unit/coverage_reporting_test.sh index 76957e5d..bf09c265 100644 --- a/tests/unit/coverage_reporting_test.sh +++ b/tests/unit/coverage_reporting_test.sh @@ -35,7 +35,8 @@ function set_up() { function tear_down() { # Clean up any coverage temp files created by tests - if [[ -n "$_BASHUNIT_COVERAGE_DATA_FILE" && "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]]; then + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then local coverage_dir coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") rm -rf "$coverage_dir" 2>/dev/null || true @@ -45,27 +46,27 @@ function tear_down() { _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" - if [[ -n "$_ORIG_COVERAGE" ]]; then + if [ -n "$_ORIG_COVERAGE" ]; then export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" else unset BASHUNIT_COVERAGE fi - if [[ -n "$_ORIG_COVERAGE_PATHS" ]]; then + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" else unset BASHUNIT_COVERAGE_PATHS fi - if [[ -n "$_ORIG_COVERAGE_EXCLUDE" ]]; then + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" else unset BASHUNIT_COVERAGE_EXCLUDE fi - if [[ -n "$_ORIG_COVERAGE_REPORT" ]]; then + if [ -n "$_ORIG_COVERAGE_REPORT" ]; then export BASHUNIT_COVERAGE_REPORT="$_ORIG_COVERAGE_REPORT" else unset BASHUNIT_COVERAGE_REPORT fi - if [[ -n "$_ORIG_COVERAGE_MIN" ]]; then + if [ -n "$_ORIG_COVERAGE_MIN" ]; then export BASHUNIT_COVERAGE_MIN="$_ORIG_COVERAGE_MIN" else unset BASHUNIT_COVERAGE_MIN @@ -395,3 +396,509 @@ EOF rm -f "$temp_file" } + +function test_coverage_report_lcov_includes_branch_records_for_if_else() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "x" ]; then + echo "taken" +else + echo "not-taken" +fi +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + assert_contains "BRDA:2,0,0,1" "$content" + assert_contains "BRDA:2,0,1,0" "$content" + assert_contains "BRF:2" "$content" + assert_contains "BRH:1" "$content" + + rm -f "$temp_file" "$report_file" +} + +function test_coverage_report_lcov_includes_branch_records_for_case() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +case "$1" in +a) + echo "got a" + ;; +b) + echo "got b" + ;; +*) + echo "other" + ;; +esac +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:7" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + # Three case arms: only the second was hit + assert_contains "BRDA:2,0,0,0" "$content" + assert_contains "BRDA:2,0,1,1" "$content" + assert_contains "BRDA:2,0,2,0" "$content" + assert_contains "BRF:3" "$content" + assert_contains "BRH:1" "$content" + + rm -f "$temp_file" "$report_file" +} + +function test_coverage_report_lcov_omits_branch_records_when_none_present() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "no branches" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + assert_not_contains "BRDA:" "$content" + assert_contains "BRF:0" "$content" + assert_contains "BRH:0" "$content" + + rm -f "$temp_file" "$report_file" +} + +function test_coverage_report_lcov_includes_function_records() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +function alpha() { + echo "in alpha" +} +function beta() { + echo "in beta" +} +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + # Hit alpha body (line 3) only; beta body (line 6) not hit + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local report_file + report_file=$(mktemp) + bashunit::coverage::report_lcov "$report_file" + + local content + content=$(cat "$report_file") + + assert_contains "FN:2,alpha" "$content" + assert_contains "FN:5,beta" "$content" + assert_contains "FNDA:1,alpha" "$content" + assert_contains "FNDA:0,beta" "$content" + assert_contains "FNF:2" "$content" + assert_contains "FNH:1" "$content" + + rm -f "$temp_file" "$report_file" +} + +function test_coverage_report_text_includes_function_summary_when_enabled() { + BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_SHOW_FUNCTIONS="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +function alpha() { + echo "alpha" +} +function beta() { + echo "beta" +} +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local output + output=$(bashunit::coverage::report_text) + + assert_contains "alpha" "$output" + assert_contains "beta" "$output" + assert_contains "Functions" "$output" + + unset BASHUNIT_COVERAGE_SHOW_FUNCTIONS + rm -f "$temp_file" +} + +function test_coverage_report_text_omits_function_summary_by_default() { + BASHUNIT_COVERAGE="true" + unset BASHUNIT_COVERAGE_SHOW_FUNCTIONS + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +function only_fn() { + echo "x" +} +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local output + output=$(bashunit::coverage::report_text) + + assert_not_contains "only_fn" "$output" + + rm -f "$temp_file" +} + +function test_coverage_html_renders_test_attribution_tooltip() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered line" +EOF + + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + echo "${temp_file}:2|tests/unit/sample_test.sh:test_should_do_thing" \ + >>"$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local content + content=$(cat "$out_html") + + assert_contains 'class="hits-tooltip"' "$content" + assert_contains "Tests hitting this line" "$content" + assert_contains "sample_test.sh" "$content" + assert_contains "test_should_do_thing" "$content" + assert_contains 'class="hits-badge has-tooltip"' "$content" + + rm -f "$temp_file" "$out_html" +} + +function test_coverage_html_tooltip_dedupes_repeated_test_hits() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +EOF + + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + # Same test recorded multiple times (typical for loops) + { + echo "${temp_file}:2|tests/unit/dup_test.sh:test_one" + echo "${temp_file}:2|tests/unit/dup_test.sh:test_one" + echo "${temp_file}:2|tests/unit/dup_test.sh:test_one" + } >>"$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local count + count=$(grep -c "test_one" "$out_html" || true) + + # Tooltip should list test_one exactly once despite multiple records + assert_equals "1" "$count" + + rm -f "$temp_file" "$out_html" +} + +function test_coverage_html_omits_tooltip_when_no_test_data() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "no tests recorded" +EOF + + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local content + content=$(cat "$out_html") + + assert_not_contains "Tests hitting this line" "$content" + assert_not_contains 'class="hits-badge has-tooltip"' "$content" + + rm -f "$temp_file" "$out_html" +} + +function test_coverage_html_index_contains_overall_metrics() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "a" +echo "b" +echo "c" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + { + echo "${temp_file}:2" + echo "${temp_file}:3" + } >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local out_dir + out_dir=$(mktemp -d) + + bashunit::coverage::report_html "$out_dir" >/dev/null + + assert_file_exists "$out_dir/index.html" + + local index + index=$(cat "$out_dir/index.html") + + assert_contains "Code Coverage Report" "$index" + assert_contains "Overall Code Coverage" "$index" + # 2 of 3 executable lines hit -> 66% + assert_contains "66%" "$index" + assert_contains "$(basename "$temp_file")" "$index" + + rm -rf "$out_dir" + rm -f "$temp_file" +} + +function test_coverage_html_index_creates_per_file_pages() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local out_dir + out_dir=$(mktemp -d) + + bashunit::coverage::report_html "$out_dir" >/dev/null + + # Per-file HTML page exists under files/ + local file_pages + file_pages=$(find "$out_dir/files/" -maxdepth 1 -type f -name '*.html' | wc -l | tr -d ' ') + assert_equals "1" "$file_pages" + + rm -rf "$out_dir" + rm -f "$temp_file" +} + +function test_coverage_html_file_page_marks_covered_and_uncovered_rows() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +echo "uncovered" +EOF + + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local content + content=$(cat "$out_html") + + assert_contains 'class="covered line-anchor"' "$content" + assert_contains 'class="uncovered line-anchor"' "$content" + # Line 1 (shebang) is non-executable -> no covered/uncovered class + assert_contains 'id="line-1" class=" line-anchor"' "$content" + + rm -f "$temp_file" "$out_html" +} + +function test_coverage_html_file_page_escapes_special_chars() { + BASHUNIT_COVERAGE="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo " & 'quote'" +EOF + + local out_html + out_html=$(mktemp) + bashunit::coverage::generate_file_html "$temp_file" "$out_html" + + local content + content=$(cat "$out_html") + + assert_contains "<tag>" "$content" + assert_contains "&" "$content" + # Raw must not appear in the code cell content + assert_not_contains 'echo "' "$content" + + rm -f "$temp_file" "$out_html" +} + +function test_coverage_report_text_lists_uncovered_hotspots_when_enabled() { + BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_SHOW_UNCOVERED="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +echo "uncovered-1" +echo "uncovered-2" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local output + output=$(bashunit::coverage::report_text) + + assert_contains "Uncovered" "$output" + # Lines 3 and 4 are uncovered, rendered as a compressed range "3-4" + assert_contains "3-4" "$output" + + unset BASHUNIT_COVERAGE_SHOW_UNCOVERED + rm -f "$temp_file" +} + +function test_coverage_report_text_uncovered_renders_singletons_separately() { + BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_SHOW_UNCOVERED="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "uncovered-2" +echo "covered-3" +echo "uncovered-4" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:3" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local output + output=$(bashunit::coverage::report_text) + + # Non-consecutive uncovered lines stay as individual entries + assert_contains "2,4" "$output" + + unset BASHUNIT_COVERAGE_SHOW_UNCOVERED + rm -f "$temp_file" +} + +function test_coverage_report_text_omits_uncovered_section_by_default() { + BASHUNIT_COVERAGE="true" + unset BASHUNIT_COVERAGE_SHOW_UNCOVERED + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "uncovered" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + local output + output=$(bashunit::coverage::report_text) + + assert_not_contains "Uncovered" "$output" + + rm -f "$temp_file" +} + +function test_coverage_report_text_skips_uncovered_section_when_no_misses() { + BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_SHOW_UNCOVERED="true" + bashunit::coverage::init + + local temp_file + temp_file=$(mktemp) + cat >"$temp_file" <<'EOF' +#!/usr/bin/env bash +echo "covered" +EOF + + echo "$temp_file" >"$_BASHUNIT_COVERAGE_TRACKED_FILES" + echo "${temp_file}:2" >>"$_BASHUNIT_COVERAGE_DATA_FILE" + + local output + output=$(bashunit::coverage::report_text) + + assert_not_contains "Uncovered" "$output" + + unset BASHUNIT_COVERAGE_SHOW_UNCOVERED + rm -f "$temp_file" +} diff --git a/tests/unit/coverage_subshell_test.sh b/tests/unit/coverage_subshell_test.sh new file mode 100644 index 00000000..2f60372e --- /dev/null +++ b/tests/unit/coverage_subshell_test.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Subshell tracking edge cases. +# bashunit relies on `set -T` plus the DEBUG trap so child shell contexts +# inherit the recorder. These tests pin the documented behavior so future +# regressions surface as failing tests instead of silent gaps. + +_ORIG_COVERAGE_DATA_FILE="" +_ORIG_COVERAGE_TRACKED_FILES="" +_ORIG_COVERAGE_TRACKED_CACHE_FILE="" +_ORIG_COVERAGE_TEST_HITS_FILE="" +_ORIG_COVERAGE="" +_ORIG_COVERAGE_PATHS="" +_ORIG_COVERAGE_EXCLUDE="" + +# Whole-suite skip: enabling the DEBUG trap inside a parallel test +# worker process makes the worker fire the trap on every internal +# coordination command, which combines with file-I/O contention on +# /tmp to deadlock CI runners. The contracts tested here are +# deterministic in single-process mode, so the parallel run is not +# a useful execution context. +function _skip_when_parallel_or_windows() { + if [ "${BASHUNIT_PARALLEL_RUN:-false}" = "true" ]; then + bashunit::skip "subshell tracking tests require single-process execution" + return 0 + fi + case "$(uname -s 2>/dev/null)" in + CYGWIN* | MINGW* | MSYS*) + bashunit::skip "DEBUG trap + set -T behavior is unstable on Git Bash" + return 0 + ;; + esac + return 1 +} + +function set_up() { + _ORIG_COVERAGE_DATA_FILE="$_BASHUNIT_COVERAGE_DATA_FILE" + _ORIG_COVERAGE_TRACKED_FILES="$_BASHUNIT_COVERAGE_TRACKED_FILES" + _ORIG_COVERAGE_TRACKED_CACHE_FILE="$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + _ORIG_COVERAGE_TEST_HITS_FILE="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" + _ORIG_COVERAGE="${BASHUNIT_COVERAGE:-}" + _ORIG_COVERAGE_PATHS="${BASHUNIT_COVERAGE_PATHS:-}" + _ORIG_COVERAGE_EXCLUDE="${BASHUNIT_COVERAGE_EXCLUDE:-}" + + _BASHUNIT_COVERAGE_DATA_FILE="" + _BASHUNIT_COVERAGE_TRACKED_FILES="" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="" + export BASHUNIT_COVERAGE="true" + export BASHUNIT_COVERAGE_PATHS="${TMPDIR:-/tmp}" + export BASHUNIT_COVERAGE_EXCLUDE="*_test.sh" +} + +function tear_down() { + trap - DEBUG + set +T + + if [ -n "$_BASHUNIT_COVERAGE_DATA_FILE" ] && + [ "$_BASHUNIT_COVERAGE_DATA_FILE" != "$_ORIG_COVERAGE_DATA_FILE" ]; then + local coverage_dir + coverage_dir=$(dirname "$_BASHUNIT_COVERAGE_DATA_FILE") + rm -rf "$coverage_dir" 2>/dev/null || true + fi + + _BASHUNIT_COVERAGE_DATA_FILE="$_ORIG_COVERAGE_DATA_FILE" + _BASHUNIT_COVERAGE_TRACKED_FILES="$_ORIG_COVERAGE_TRACKED_FILES" + _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="$_ORIG_COVERAGE_TRACKED_CACHE_FILE" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="$_ORIG_COVERAGE_TEST_HITS_FILE" + + if [ -n "$_ORIG_COVERAGE" ]; then + export BASHUNIT_COVERAGE="$_ORIG_COVERAGE" + else + unset BASHUNIT_COVERAGE + fi + if [ -n "$_ORIG_COVERAGE_PATHS" ]; then + export BASHUNIT_COVERAGE_PATHS="$_ORIG_COVERAGE_PATHS" + else + unset BASHUNIT_COVERAGE_PATHS + fi + if [ -n "$_ORIG_COVERAGE_EXCLUDE" ]; then + export BASHUNIT_COVERAGE_EXCLUDE="$_ORIG_COVERAGE_EXCLUDE" + else + unset BASHUNIT_COVERAGE_EXCLUDE + fi +} + +# Helper: run a fixture under coverage tracking and return how many +# distinct hit lines were recorded for it. +function _run_fixture_under_coverage() { + local fixture="$1" + bashunit::coverage::init + echo "$fixture" >>"$_BASHUNIT_COVERAGE_TRACKED_FILES" + + bashunit::coverage::enable_trap + # shellcheck disable=SC1090 + source "$fixture" >/dev/null 2>&1 + bashunit::coverage::disable_trap + + # When the suite itself runs in parallel mode, hits are flushed to a + # per-PID data file. Aggregate so the assertion below sees them. + bashunit::coverage::aggregate_parallel + + bashunit::coverage::get_all_line_hits "$fixture" | wc -l | tr -d ' ' +} + +function test_coverage_records_lines_inside_command_substitution() { + _skip_when_parallel_or_windows && return 0 + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +result=$(echo "inside-subst") +echo "after $result" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Documented limitation: the outer line containing $(...) is recorded, + # but the command inside the subshell does not propagate hits back to + # the parent's coverage data file. Both outer lines should be hit. + assert_equals "2" "$hit_count" + + rm -f "$fixture" +} + +function test_coverage_records_explicit_subshell_block() { + _skip_when_parallel_or_windows && return 0 + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +( + echo "in subshell" +) +echo "after" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Documented limitation: writes from inside ( ... ) hit the in-memory + # buffer of the subshell, which is discarded on subshell exit. Only + # the outer `echo "after"` line is recorded back in the parent. + assert_equals "1" "$hit_count" + + rm -f "$fixture" +} + +function test_coverage_records_pipeline_lhs() { + _skip_when_parallel_or_windows && return 0 + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +echo "one" | cat >/dev/null +echo "two" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Each pipeline source line is recorded once (the pipeline as a unit). + assert_equals "2" "$hit_count" + + rm -f "$fixture" +} + +function test_coverage_records_process_substitution_consumer() { + _skip_when_parallel_or_windows && return 0 + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +while read -r _line; do + : "$_line" +done < <(echo "a") +echo "after" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Consumer side of <(...) is tracked: the `while` line, the loop body, + # and the trailing echo (3 distinct hit lines). + assert_equals "3" "$hit_count" + + rm -f "$fixture" +} + +function test_coverage_records_lines_inside_function_called_from_subshell() { + _skip_when_parallel_or_windows && return 0 + local fixture + fixture=$(mktemp) + cat >"$fixture" <<'EOF' +function _sub_helper() { + echo "in helper" +} +result=$(_sub_helper) +echo "after $result" +EOF + + local hit_count + hit_count=$(_run_fixture_under_coverage "$fixture") + + # Documented limitation: the function body runs inside the $(...) + # subshell, so its hits are lost. Only the caller line and trailing + # echo are recorded in the parent's data file. + assert_equals "2" "$hit_count" + + rm -f "$fixture" +}