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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
## Unreleased

### Changed
- Performance: `--coverage-report-html` no longer forks twice per source line to escape it — 58.7s to 9.5s for 128 files here, with the escaping done once per file and the per-page `wc`, `basename` and `pwd` calls replaced by parameter expansions (#1096)
- Performance: the coverage report picks its colours without a subshell per file and per function — the text report over 128 files went from 387ms to 318ms, and 4042ms to 3116ms with `BASHUNIT_COVERAGE_SHOW_FUNCTIONS` on (#1092)

## [0.47.0](https://github.com/TypedDevs/bashunit/compare/0.46.0...0.47.0) - 2026-08-13
Expand Down
30 changes: 20 additions & 10 deletions src/coverage/html_file.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ function bashunit::coverage::generate_file_html() {
local file="$1"
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
local display_file="${file#"$PWD"/}"
local executable hit pct class stats
stats=$(bashunit::coverage::get_cached_stats "$file")
bashunit::coverage::split_stats "$stats"
Expand All@@ -27,6 +27,16 @@ function bashunit::coverage::generate_file_html() {
((++_fli))
done <"$file"

# And their escaped form, in ONE awk pass for the whole file. Escaping per
# line cost a command substitution and a sed each -- about 22,000 processes
# for this repo, 58.7s of HTML report (#1096).
local -a escaped_lines=()
local _eli=0 _el
while IFS= read -r _el || [ -n "$_el" ]; do
escaped_lines[_eli]="$_el"
((++_eli))
done < <(bashunit::coverage::html_escape_file "$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.0 compatibility (no associative arrays)
Expand All@@ -53,8 +63,7 @@ function bashunit::coverage::generate_file_html() {
done < <(bashunit::coverage::get_all_line_tests "$file")

# Count total lines and functions
local total_lines
total_lines=$(wc -l <"$file" | tr -d ' ')
local total_lines="${#file_lines[@]}"
local non_executable=$((total_lines - executable))

{
Expand All@@ -65,7 +74,7 @@ function bashunit::coverage::generate_file_html() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
EOF
echo " <title>$(basename "$display_file") | Coverage Report</title>"
echo " <title>${display_file##*/} | Coverage Report</title>"
cat <<'EOF'
<style>
:root {
Expand DownExpand Up@@ -204,7 +213,7 @@ EOF
<a href="../index.html" class="back-btn">← Back to Overview</a>
<div class="file-title">
EOF
echo " <span class=\"file-name\">$(basename "$display_file")</span>"
echo " <span class=\"file-name\">${display_file##*/}</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -306,7 +315,10 @@ EOF
done

local fn_pct fn_class row_class
fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
case "$fn_class" in
Expand DownExpand Up@@ -354,8 +366,7 @@ EOF
for line in "${file_lines[@]}"; do
((++lineno))

local escaped_line
escaped_line=$(bashunit::coverage::html_escape "$line")
local escaped_line="${escaped_lines[$((lineno - 1))]:-}"

local row_class=""
local hits_display=""
Expand All@@ -375,8 +386,7 @@ EOF
local test_file test_fn
while IFS=':' read -r test_file test_fn; do
[ -z "$test_file" ] && continue
local short_file
short_file=$(basename "$test_file")
local short_file="${test_file##*/}"
tooltip_html="$tooltip_html<li><span class=\"hits-tooltip-file\">${short_file}</span>:<span class=\"hits-tooltip-fn\">${test_fn}</span></li>"
done <<<"$test_info"
tooltip_html="$tooltip_html</ul></div>"
Expand Down
2 changes: 1 addition & 1 deletion src/coverage/html_index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,7 +325,7 @@ EOF
echo " <tr onclick=\"window.location='files/${safe_filename}.html'\">"
echo " <td>"
echo " <div class=\"file-info\">"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">$(basename "$display_file")</a>"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">${display_file##*/}</a>"
echo " <div class=\"file-path\">./${display_file}</div>"
echo " </div>"
echo " </td>"
Expand Down
35 changes: 33 additions & 2 deletions src/coverage/report_html.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,44 @@

# HTML coverage report: orchestration and shared helpers.

# Escape HTML special characters
# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings)
# Escape HTML special characters.
#
# This cannot be `${text//&/&amp;}`. Bash 5.2 made a bare `&` in the
# REPLACEMENT mean "the matched text", so `${text//</&lt;}` yields `<lt;`
# there, while escaping it as `\&` to satisfy 5.2 emits a literal backslash on
# 3.2. No single pattern-substitution form is right across the supported range,
# and both failure modes are silent, so the escaping goes through a tool with
# stable semantics.
function bashunit::coverage::html_escape() {
local text="$1"
printf "%s" "$text" | sed "s/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g"
}

# The same escaping for a whole file, one awk pass, emitting one escaped line
# per source line in order.
#
# The per-line call above cost a command substitution AND a `sed` for every
# line of every page: about 22,000 processes for this repo, which is why an
# HTML report took 58.7s (#1096). In awk the replacement metacharacter is `&`
# too, hence the `\&` in each replacement.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE='
{
gsub(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
print
}
'

##
# Escapes every line of $1, one line of output per line of input.
# Arguments: $1 - source file
##
function bashunit::coverage::html_escape_file() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE" "$1"
}

function bashunit::coverage::report_html() {
local output_dir="${1:-coverage/html}"

Expand Down
5 changes: 4 additions & 1 deletion src/coverage/report_text.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,7 +266,10 @@ function bashunit::coverage::report_text_functions() {
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
bashunit::coverage::color_to_slot "$fn_class"
Expand Down
113 changes: 113 additions & 0 deletions tests/acceptance/bashunit_html_forks_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for the HTML report.
#
# Every source line it prints has to be HTML-escaped, and that escaping used to
# be a command substitution plus a `sed` PER LINE: about 22,000 processes for
# this repo and 58.7s of report (#1096). It is one awk pass per file now.
#
# The budget is expressed as "does not grow with the number of source lines",
# the same property tests/acceptance/bashunit_coverage_forks_test.sh pins for
# the classifier, because that is what was fixed and it needs no retuning when
# an unrelated `sed` appears elsewhere.

# Writes a fixture pair into $1: a test file plus a source file of $2 body units,
# each unit holding characters the escaper has to rewrite.
function _html_fixture() {
local dir="$1"
local units="$2"

{
echo '#!/usr/bin/env bash'
echo 'function covered_fn() {'
echo ' local total=0'
local i
for i in $(seq 1 "$units"); do
echo " # a & b <tag> $i"
echo " total=\$((total + $i))"
echo " printf '%s' \"<x> & <y>\""
done
echo ' echo "$total"'
echo '}'
} >"$dir/libhtml.sh"

{
echo "source \"$dir/libhtml.sh\""
echo 'function test_covers_source() { assert_not_empty "$(covered_fn)"; }'
} >"$dir/html_forks_test.sh"
}

# Runs an HTML coverage report with a `sed` PATH shim and echoes the fork count.
function _count_sed_forks_for_html() {
local dir="$1"
local real_sed="$2"
local count_file="$dir/sed_count"

{
echo '#!/usr/bin/env bash'
echo "echo x >> \"$count_file\""
echo "exec \"$real_sed\" \"\$@\""
} >"$dir/sed"
chmod +x "$dir/sed"
: >"$count_file"

PATH="$dir:$PATH" \
BASHUNIT_COVERAGE_PATHS="$dir" \
./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

local forks=0
if [ -f "$count_file" ]; then
forks="$(grep -c . "$count_file" || true)"
fi
echo "$forks"
}

function test_the_html_report_does_not_fork_sed_per_source_line() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local real_sed
real_sed="$(command -v sed)"

# Canonicalise: bashunit::temp_dir can yield a doubled slash and
# BASHUNIT_COVERAGE_PATHS is prefix-matched against canonicalised paths, so a
# mismatch would track nothing and the census would measure an empty run.
local small_dir large_dir
small_dir="$(cd "$(bashunit::temp_dir)" && pwd)"
large_dir="$(cd "$(bashunit::temp_dir)" && pwd)"

# 3 lines per unit: the large fixture has ~120 more lines to escape, which
# used to cost one sed each.
_html_fixture "$small_dir" 2
_html_fixture "$large_dir" 42

local small_forks large_forks
small_forks="$(_count_sed_forks_for_html "$small_dir" "$real_sed")"
large_forks="$(_count_sed_forks_for_html "$large_dir" "$real_sed")"

assert_less_than 10 "$((large_forks - small_forks))"
}

function test_the_html_report_renders_the_escaped_source() {
local dir
dir="$(cd "$(bashunit::temp_dir)" && pwd)"
_html_fixture "$dir" 1

BASHUNIT_COVERAGE_PATHS="$dir" ./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

# Pages are named after the whole mangled path, not the basename.
local page
page="$(find "$dir/html" -name '*libhtml_sh.html' | head -1)"
assert_not_empty "$page"

# The markup in the source must arrive escaped, not as markup.
local body
body="$(cat "$page")"
assert_contains "&lt;tag&gt;" "$body"
assert_contains "&amp;" "$body"
assert_not_contains '<x> & <y>' "$body"
}
70 changes: 70 additions & 0 deletions tests/unit/coverage/html_escape_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

# The HTML report escapes every source line it prints. It used to do that with
# a command substitution and a sed PER LINE -- about 22,000 processes for this
# repo, 58.7s of report (#1096) -- and now does it once per file. The two must
# produce the same bytes, or the report silently renders markup as markup.
#
# Escaping in pure Bash is not an option here, and the failure is silent both
# ways: Bash 5.2 made a bare `&` in a substitution REPLACEMENT mean "the
# matched text", so `${line//</&lt;}` yields `<lt;` on 5.2+, while writing it
# as `\&` for 5.2 emits a literal backslash on 3.2. These tests pin the output
# itself, so either mistake fails here rather than in a rendered page.

function fixture_with() { # $@ = lines
local file
file="$(bashunit::temp_file html_escape).txt"
printf '%s\n' "$@" >"$file"
echo "$file"
}

function test_the_whole_file_escaper_matches_the_per_line_one() {
local file
file="$(fixture_with \
'a & b' \
'<div class="x">' \
'x > y && z' \
'if [ "$a" -lt "$b" ]; then' \
'printf "%s\n" "<tag>"' \
'&amp; already escaped' \
'back\slash <tag>' \
'no specials here')"

local expected=""
local line
while IFS= read -r line || [ -n "$line" ]; do
expected="${expected}$(bashunit::coverage::html_escape "$line")
"
done <"$file"

assert_same "$expected" "$(bashunit::coverage::html_escape_file "$file")
"
}

# The three substitutions, spelled out. `<` must become `&lt;` and not `<lt;`,
# which is what a bare `&` in a Bash 5.2 replacement would produce.
function test_the_ampersand_is_not_a_backreference() {
local file
file="$(fixture_with '<a href="x">A & B</a>' 'x > y')"

assert_same '&lt;a href="x"&gt;A &amp; B&lt;/a&gt;
x &gt; y' "$(bashunit::coverage::html_escape_file "$file")"
}

# `&` has to be replaced before `<` and `>`, or the `&` of an already-emitted
# `&lt;` gets escaped again into `&amp;lt;`.
function test_the_ampersand_is_replaced_before_the_angle_brackets() {
local file
file="$(fixture_with '<x>')"

assert_same '&lt;x&gt;' "$(bashunit::coverage::html_escape_file "$file")"
}

function test_an_empty_line_stays_an_empty_line() {
local file
file="$(fixture_with 'a' '' 'b')"

assert_same 'a

b' "$(bashunit::coverage::html_escape_file "$file")"
}
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(coverage): escape the HTML report once per file, not twice per line by Chemaclass · Pull Request #1097 · 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
## Unreleased

### Changed
- Performance: `--coverage-report-html` no longer forks twice per source line to escape it — 58.7s to 9.5s for 128 files here, with the escaping done once per file and the per-page `wc`, `basename` and `pwd` calls replaced by parameter expansions (#1096)
- Performance: the coverage report picks its colours without a subshell per file and per function — the text report over 128 files went from 387ms to 318ms, and 4042ms to 3116ms with `BASHUNIT_COVERAGE_SHOW_FUNCTIONS` on (#1092)

## [0.47.0](https://github.com/TypedDevs/bashunit/compare/0.46.0...0.47.0) - 2026-08-13
Expand Down
30 changes: 20 additions & 10 deletions src/coverage/html_file.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ function bashunit::coverage::generate_file_html() {
local file="$1"
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
local display_file="${file#"$PWD"/}"
local executable hit pct class stats
stats=$(bashunit::coverage::get_cached_stats "$file")
bashunit::coverage::split_stats "$stats"
Expand All@@ -27,6 +27,16 @@ function bashunit::coverage::generate_file_html() {
((++_fli))
done <"$file"

# And their escaped form, in ONE awk pass for the whole file. Escaping per
# line cost a command substitution and a sed each -- about 22,000 processes
# for this repo, 58.7s of HTML report (#1096).
local -a escaped_lines=()
local _eli=0 _el
while IFS= read -r _el || [ -n "$_el" ]; do
escaped_lines[_eli]="$_el"
((++_eli))
done < <(bashunit::coverage::html_escape_file "$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.0 compatibility (no associative arrays)
Expand All@@ -53,8 +63,7 @@ function bashunit::coverage::generate_file_html() {
done < <(bashunit::coverage::get_all_line_tests "$file")

# Count total lines and functions
local total_lines
total_lines=$(wc -l <"$file" | tr -d ' ')
local total_lines="${#file_lines[@]}"
local non_executable=$((total_lines - executable))

{
Expand All@@ -65,7 +74,7 @@ function bashunit::coverage::generate_file_html() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
EOF
echo " <title>$(basename "$display_file") | Coverage Report</title>"
echo " <title>${display_file##*/} | Coverage Report</title>"
cat <<'EOF'
<style>
:root {
Expand DownExpand Up@@ -204,7 +213,7 @@ EOF
<a href="../index.html" class="back-btn">← Back to Overview</a>
<div class="file-title">
EOF
echo " <span class=\"file-name\">$(basename "$display_file")</span>"
echo " <span class=\"file-name\">${display_file##*/}</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -306,7 +315,10 @@ EOF
done

local fn_pct fn_class row_class
fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
case "$fn_class" in
Expand DownExpand Up@@ -354,8 +366,7 @@ EOF
for line in "${file_lines[@]}"; do
((++lineno))

local escaped_line
escaped_line=$(bashunit::coverage::html_escape "$line")
local escaped_line="${escaped_lines[$((lineno - 1))]:-}"

local row_class=""
local hits_display=""
Expand All@@ -375,8 +386,7 @@ EOF
local test_file test_fn
while IFS=':' read -r test_file test_fn; do
[ -z "$test_file" ] && continue
local short_file
short_file=$(basename "$test_file")
local short_file="${test_file##*/}"
tooltip_html="$tooltip_html<li><span class=\"hits-tooltip-file\">${short_file}</span>:<span class=\"hits-tooltip-fn\">${test_fn}</span></li>"
done <<<"$test_info"
tooltip_html="$tooltip_html</ul></div>"
Expand Down
2 changes: 1 addition & 1 deletion src/coverage/html_index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,7 +325,7 @@ EOF
echo " <tr onclick=\"window.location='files/${safe_filename}.html'\">"
echo " <td>"
echo " <div class=\"file-info\">"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">$(basename "$display_file")</a>"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">${display_file##*/}</a>"
echo " <div class=\"file-path\">./${display_file}</div>"
echo " </div>"
echo " </td>"
Expand Down
35 changes: 33 additions & 2 deletions src/coverage/report_html.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,44 @@

# HTML coverage report: orchestration and shared helpers.

# Escape HTML special characters
# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings)
# Escape HTML special characters.
#
# This cannot be `${text//&/&amp;}`. Bash 5.2 made a bare `&` in the
# REPLACEMENT mean "the matched text", so `${text//</&lt;}` yields `<lt;`
# there, while escaping it as `\&` to satisfy 5.2 emits a literal backslash on
# 3.2. No single pattern-substitution form is right across the supported range,
# and both failure modes are silent, so the escaping goes through a tool with
# stable semantics.
function bashunit::coverage::html_escape() {
local text="$1"
printf "%s" "$text" | sed "s/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g"
}

# The same escaping for a whole file, one awk pass, emitting one escaped line
# per source line in order.
#
# The per-line call above cost a command substitution AND a `sed` for every
# line of every page: about 22,000 processes for this repo, which is why an
# HTML report took 58.7s (#1096). In awk the replacement metacharacter is `&`
# too, hence the `\&` in each replacement.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE='
{
gsub(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
print
}
'

##
# Escapes every line of $1, one line of output per line of input.
# Arguments: $1 - source file
##
function bashunit::coverage::html_escape_file() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE" "$1"
}

function bashunit::coverage::report_html() {
local output_dir="${1:-coverage/html}"

Expand Down
5 changes: 4 additions & 1 deletion src/coverage/report_text.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,7 +266,10 @@ function bashunit::coverage::report_text_functions() {
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
bashunit::coverage::color_to_slot "$fn_class"
Expand Down
113 changes: 113 additions & 0 deletions tests/acceptance/bashunit_html_forks_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for the HTML report.
#
# Every source line it prints has to be HTML-escaped, and that escaping used to
# be a command substitution plus a `sed` PER LINE: about 22,000 processes for
# this repo and 58.7s of report (#1096). It is one awk pass per file now.
#
# The budget is expressed as "does not grow with the number of source lines",
# the same property tests/acceptance/bashunit_coverage_forks_test.sh pins for
# the classifier, because that is what was fixed and it needs no retuning when
# an unrelated `sed` appears elsewhere.

# Writes a fixture pair into $1: a test file plus a source file of $2 body units,
# each unit holding characters the escaper has to rewrite.
function _html_fixture() {
local dir="$1"
local units="$2"

{
echo '#!/usr/bin/env bash'
echo 'function covered_fn() {'
echo ' local total=0'
local i
for i in $(seq 1 "$units"); do
echo " # a & b <tag> $i"
echo " total=\$((total + $i))"
echo " printf '%s' \"<x> & <y>\""
done
echo ' echo "$total"'
echo '}'
} >"$dir/libhtml.sh"

{
echo "source \"$dir/libhtml.sh\""
echo 'function test_covers_source() { assert_not_empty "$(covered_fn)"; }'
} >"$dir/html_forks_test.sh"
}

# Runs an HTML coverage report with a `sed` PATH shim and echoes the fork count.
function _count_sed_forks_for_html() {
local dir="$1"
local real_sed="$2"
local count_file="$dir/sed_count"

{
echo '#!/usr/bin/env bash'
echo "echo x >> \"$count_file\""
echo "exec \"$real_sed\" \"\$@\""
} >"$dir/sed"
chmod +x "$dir/sed"
: >"$count_file"

PATH="$dir:$PATH" \
BASHUNIT_COVERAGE_PATHS="$dir" \
./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

local forks=0
if [ -f "$count_file" ]; then
forks="$(grep -c . "$count_file" || true)"
fi
echo "$forks"
}

function test_the_html_report_does_not_fork_sed_per_source_line() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local real_sed
real_sed="$(command -v sed)"

# Canonicalise: bashunit::temp_dir can yield a doubled slash and
# BASHUNIT_COVERAGE_PATHS is prefix-matched against canonicalised paths, so a
# mismatch would track nothing and the census would measure an empty run.
local small_dir large_dir
small_dir="$(cd "$(bashunit::temp_dir)" && pwd)"
large_dir="$(cd "$(bashunit::temp_dir)" && pwd)"

# 3 lines per unit: the large fixture has ~120 more lines to escape, which
# used to cost one sed each.
_html_fixture "$small_dir" 2
_html_fixture "$large_dir" 42

local small_forks large_forks
small_forks="$(_count_sed_forks_for_html "$small_dir" "$real_sed")"
large_forks="$(_count_sed_forks_for_html "$large_dir" "$real_sed")"

assert_less_than 10 "$((large_forks - small_forks))"
}

function test_the_html_report_renders_the_escaped_source() {
local dir
dir="$(cd "$(bashunit::temp_dir)" && pwd)"
_html_fixture "$dir" 1

BASHUNIT_COVERAGE_PATHS="$dir" ./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

# Pages are named after the whole mangled path, not the basename.
local page
page="$(find "$dir/html" -name '*libhtml_sh.html' | head -1)"
assert_not_empty "$page"

# The markup in the source must arrive escaped, not as markup.
local body
body="$(cat "$page")"
assert_contains "&lt;tag&gt;" "$body"
assert_contains "&amp;" "$body"
assert_not_contains '<x> & <y>' "$body"
}
70 changes: 70 additions & 0 deletions tests/unit/coverage/html_escape_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

# The HTML report escapes every source line it prints. It used to do that with
# a command substitution and a sed PER LINE -- about 22,000 processes for this
# repo, 58.7s of report (#1096) -- and now does it once per file. The two must
# produce the same bytes, or the report silently renders markup as markup.
#
# Escaping in pure Bash is not an option here, and the failure is silent both
# ways: Bash 5.2 made a bare `&` in a substitution REPLACEMENT mean "the
# matched text", so `${line//</&lt;}` yields `<lt;` on 5.2+, while writing it
# as `\&` for 5.2 emits a literal backslash on 3.2. These tests pin the output
# itself, so either mistake fails here rather than in a rendered page.

function fixture_with() { # $@ = lines
local file
file="$(bashunit::temp_file html_escape).txt"
printf '%s\n' "$@" >"$file"
echo "$file"
}

function test_the_whole_file_escaper_matches_the_per_line_one() {
local file
file="$(fixture_with \
'a & b' \
'<div class="x">' \
'x > y && z' \
'if [ "$a" -lt "$b" ]; then' \
'printf "%s\n" "<tag>"' \
'&amp; already escaped' \
'back\slash <tag>' \
'no specials here')"

local expected=""
local line
while IFS= read -r line || [ -n "$line" ]; do
expected="${expected}$(bashunit::coverage::html_escape "$line")
"
done <"$file"

assert_same "$expected" "$(bashunit::coverage::html_escape_file "$file")
"
}

# The three substitutions, spelled out. `<` must become `&lt;` and not `<lt;`,
# which is what a bare `&` in a Bash 5.2 replacement would produce.
function test_the_ampersand_is_not_a_backreference() {
local file
file="$(fixture_with '<a href="x">A & B</a>' 'x > y')"

assert_same '&lt;a href="x"&gt;A &amp; B&lt;/a&gt;
x &gt; y' "$(bashunit::coverage::html_escape_file "$file")"
}

# `&` has to be replaced before `<` and `>`, or the `&` of an already-emitted
# `&lt;` gets escaped again into `&amp;lt;`.
function test_the_ampersand_is_replaced_before_the_angle_brackets() {
local file
file="$(fixture_with '<x>')"

assert_same '&lt;x&gt;' "$(bashunit::coverage::html_escape_file "$file")"
}

function test_an_empty_line_stays_an_empty_line() {
local file
file="$(fixture_with 'a' '' 'b')"

assert_same 'a

b' "$(bashunit::coverage::html_escape_file "$file")"
}
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(coverage): escape the HTML report once per file, not twice per line by Chemaclass · Pull Request #1097 · 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
## Unreleased

### Changed
- Performance: `--coverage-report-html` no longer forks twice per source line to escape it — 58.7s to 9.5s for 128 files here, with the escaping done once per file and the per-page `wc`, `basename` and `pwd` calls replaced by parameter expansions (#1096)
- Performance: the coverage report picks its colours without a subshell per file and per function — the text report over 128 files went from 387ms to 318ms, and 4042ms to 3116ms with `BASHUNIT_COVERAGE_SHOW_FUNCTIONS` on (#1092)

## [0.47.0](https://github.com/TypedDevs/bashunit/compare/0.46.0...0.47.0) - 2026-08-13
Expand Down
30 changes: 20 additions & 10 deletions src/coverage/html_file.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ function bashunit::coverage::generate_file_html() {
local file="$1"
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
local display_file="${file#"$PWD"/}"
local executable hit pct class stats
stats=$(bashunit::coverage::get_cached_stats "$file")
bashunit::coverage::split_stats "$stats"
Expand All@@ -27,6 +27,16 @@ function bashunit::coverage::generate_file_html() {
((++_fli))
done <"$file"

# And their escaped form, in ONE awk pass for the whole file. Escaping per
# line cost a command substitution and a sed each -- about 22,000 processes
# for this repo, 58.7s of HTML report (#1096).
local -a escaped_lines=()
local _eli=0 _el
while IFS= read -r _el || [ -n "$_el" ]; do
escaped_lines[_eli]="$_el"
((++_eli))
done < <(bashunit::coverage::html_escape_file "$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.0 compatibility (no associative arrays)
Expand All@@ -53,8 +63,7 @@ function bashunit::coverage::generate_file_html() {
done < <(bashunit::coverage::get_all_line_tests "$file")

# Count total lines and functions
local total_lines
total_lines=$(wc -l <"$file" | tr -d ' ')
local total_lines="${#file_lines[@]}"
local non_executable=$((total_lines - executable))

{
Expand All@@ -65,7 +74,7 @@ function bashunit::coverage::generate_file_html() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
EOF
echo " <title>$(basename "$display_file") | Coverage Report</title>"
echo " <title>${display_file##*/} | Coverage Report</title>"
cat <<'EOF'
<style>
:root {
Expand DownExpand Up@@ -204,7 +213,7 @@ EOF
<a href="../index.html" class="back-btn">← Back to Overview</a>
<div class="file-title">
EOF
echo " <span class=\"file-name\">$(basename "$display_file")</span>"
echo " <span class=\"file-name\">${display_file##*/}</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -306,7 +315,10 @@ EOF
done

local fn_pct fn_class row_class
fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
case "$fn_class" in
Expand DownExpand Up@@ -354,8 +366,7 @@ EOF
for line in "${file_lines[@]}"; do
((++lineno))

local escaped_line
escaped_line=$(bashunit::coverage::html_escape "$line")
local escaped_line="${escaped_lines[$((lineno - 1))]:-}"

local row_class=""
local hits_display=""
Expand All@@ -375,8 +386,7 @@ EOF
local test_file test_fn
while IFS=':' read -r test_file test_fn; do
[ -z "$test_file" ] && continue
local short_file
short_file=$(basename "$test_file")
local short_file="${test_file##*/}"
tooltip_html="$tooltip_html<li><span class=\"hits-tooltip-file\">${short_file}</span>:<span class=\"hits-tooltip-fn\">${test_fn}</span></li>"
done <<<"$test_info"
tooltip_html="$tooltip_html</ul></div>"
Expand Down
2 changes: 1 addition & 1 deletion src/coverage/html_index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,7 +325,7 @@ EOF
echo " <tr onclick=\"window.location='files/${safe_filename}.html'\">"
echo " <td>"
echo " <div class=\"file-info\">"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">$(basename "$display_file")</a>"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">${display_file##*/}</a>"
echo " <div class=\"file-path\">./${display_file}</div>"
echo " </div>"
echo " </td>"
Expand Down
35 changes: 33 additions & 2 deletions src/coverage/report_html.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,44 @@

# HTML coverage report: orchestration and shared helpers.

# Escape HTML special characters
# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings)
# Escape HTML special characters.
#
# This cannot be `${text//&/&amp;}`. Bash 5.2 made a bare `&` in the
# REPLACEMENT mean "the matched text", so `${text//</&lt;}` yields `<lt;`
# there, while escaping it as `\&` to satisfy 5.2 emits a literal backslash on
# 3.2. No single pattern-substitution form is right across the supported range,
# and both failure modes are silent, so the escaping goes through a tool with
# stable semantics.
function bashunit::coverage::html_escape() {
local text="$1"
printf "%s" "$text" | sed "s/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g"
}

# The same escaping for a whole file, one awk pass, emitting one escaped line
# per source line in order.
#
# The per-line call above cost a command substitution AND a `sed` for every
# line of every page: about 22,000 processes for this repo, which is why an
# HTML report took 58.7s (#1096). In awk the replacement metacharacter is `&`
# too, hence the `\&` in each replacement.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE='
{
gsub(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
print
}
'

##
# Escapes every line of $1, one line of output per line of input.
# Arguments: $1 - source file
##
function bashunit::coverage::html_escape_file() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE" "$1"
}

function bashunit::coverage::report_html() {
local output_dir="${1:-coverage/html}"

Expand Down
5 changes: 4 additions & 1 deletion src/coverage/report_text.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,7 +266,10 @@ function bashunit::coverage::report_text_functions() {
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
bashunit::coverage::color_to_slot "$fn_class"
Expand Down
113 changes: 113 additions & 0 deletions tests/acceptance/bashunit_html_forks_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for the HTML report.
#
# Every source line it prints has to be HTML-escaped, and that escaping used to
# be a command substitution plus a `sed` PER LINE: about 22,000 processes for
# this repo and 58.7s of report (#1096). It is one awk pass per file now.
#
# The budget is expressed as "does not grow with the number of source lines",
# the same property tests/acceptance/bashunit_coverage_forks_test.sh pins for
# the classifier, because that is what was fixed and it needs no retuning when
# an unrelated `sed` appears elsewhere.

# Writes a fixture pair into $1: a test file plus a source file of $2 body units,
# each unit holding characters the escaper has to rewrite.
function _html_fixture() {
local dir="$1"
local units="$2"

{
echo '#!/usr/bin/env bash'
echo 'function covered_fn() {'
echo ' local total=0'
local i
for i in $(seq 1 "$units"); do
echo " # a & b <tag> $i"
echo " total=\$((total + $i))"
echo " printf '%s' \"<x> & <y>\""
done
echo ' echo "$total"'
echo '}'
} >"$dir/libhtml.sh"

{
echo "source \"$dir/libhtml.sh\""
echo 'function test_covers_source() { assert_not_empty "$(covered_fn)"; }'
} >"$dir/html_forks_test.sh"
}

# Runs an HTML coverage report with a `sed` PATH shim and echoes the fork count.
function _count_sed_forks_for_html() {
local dir="$1"
local real_sed="$2"
local count_file="$dir/sed_count"

{
echo '#!/usr/bin/env bash'
echo "echo x >> \"$count_file\""
echo "exec \"$real_sed\" \"\$@\""
} >"$dir/sed"
chmod +x "$dir/sed"
: >"$count_file"

PATH="$dir:$PATH" \
BASHUNIT_COVERAGE_PATHS="$dir" \
./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

local forks=0
if [ -f "$count_file" ]; then
forks="$(grep -c . "$count_file" || true)"
fi
echo "$forks"
}

function test_the_html_report_does_not_fork_sed_per_source_line() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local real_sed
real_sed="$(command -v sed)"

# Canonicalise: bashunit::temp_dir can yield a doubled slash and
# BASHUNIT_COVERAGE_PATHS is prefix-matched against canonicalised paths, so a
# mismatch would track nothing and the census would measure an empty run.
local small_dir large_dir
small_dir="$(cd "$(bashunit::temp_dir)" && pwd)"
large_dir="$(cd "$(bashunit::temp_dir)" && pwd)"

# 3 lines per unit: the large fixture has ~120 more lines to escape, which
# used to cost one sed each.
_html_fixture "$small_dir" 2
_html_fixture "$large_dir" 42

local small_forks large_forks
small_forks="$(_count_sed_forks_for_html "$small_dir" "$real_sed")"
large_forks="$(_count_sed_forks_for_html "$large_dir" "$real_sed")"

assert_less_than 10 "$((large_forks - small_forks))"
}

function test_the_html_report_renders_the_escaped_source() {
local dir
dir="$(cd "$(bashunit::temp_dir)" && pwd)"
_html_fixture "$dir" 1

BASHUNIT_COVERAGE_PATHS="$dir" ./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

# Pages are named after the whole mangled path, not the basename.
local page
page="$(find "$dir/html" -name '*libhtml_sh.html' | head -1)"
assert_not_empty "$page"

# The markup in the source must arrive escaped, not as markup.
local body
body="$(cat "$page")"
assert_contains "&lt;tag&gt;" "$body"
assert_contains "&amp;" "$body"
assert_not_contains '<x> & <y>' "$body"
}
70 changes: 70 additions & 0 deletions tests/unit/coverage/html_escape_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

# The HTML report escapes every source line it prints. It used to do that with
# a command substitution and a sed PER LINE -- about 22,000 processes for this
# repo, 58.7s of report (#1096) -- and now does it once per file. The two must
# produce the same bytes, or the report silently renders markup as markup.
#
# Escaping in pure Bash is not an option here, and the failure is silent both
# ways: Bash 5.2 made a bare `&` in a substitution REPLACEMENT mean "the
# matched text", so `${line//</&lt;}` yields `<lt;` on 5.2+, while writing it
# as `\&` for 5.2 emits a literal backslash on 3.2. These tests pin the output
# itself, so either mistake fails here rather than in a rendered page.

function fixture_with() { # $@ = lines
local file
file="$(bashunit::temp_file html_escape).txt"
printf '%s\n' "$@" >"$file"
echo "$file"
}

function test_the_whole_file_escaper_matches_the_per_line_one() {
local file
file="$(fixture_with \
'a & b' \
'<div class="x">' \
'x > y && z' \
'if [ "$a" -lt "$b" ]; then' \
'printf "%s\n" "<tag>"' \
'&amp; already escaped' \
'back\slash <tag>' \
'no specials here')"

local expected=""
local line
while IFS= read -r line || [ -n "$line" ]; do
expected="${expected}$(bashunit::coverage::html_escape "$line")
"
done <"$file"

assert_same "$expected" "$(bashunit::coverage::html_escape_file "$file")
"
}

# The three substitutions, spelled out. `<` must become `&lt;` and not `<lt;`,
# which is what a bare `&` in a Bash 5.2 replacement would produce.
function test_the_ampersand_is_not_a_backreference() {
local file
file="$(fixture_with '<a href="x">A & B</a>' 'x > y')"

assert_same '&lt;a href="x"&gt;A &amp; B&lt;/a&gt;
x &gt; y' "$(bashunit::coverage::html_escape_file "$file")"
}

# `&` has to be replaced before `<` and `>`, or the `&` of an already-emitted
# `&lt;` gets escaped again into `&amp;lt;`.
function test_the_ampersand_is_replaced_before_the_angle_brackets() {
local file
file="$(fixture_with '<x>')"

assert_same '&lt;x&gt;' "$(bashunit::coverage::html_escape_file "$file")"
}

function test_an_empty_line_stays_an_empty_line() {
local file
file="$(fixture_with 'a' '' 'b')"

assert_same 'a

b' "$(bashunit::coverage::html_escape_file "$file")"
}
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(coverage): escape the HTML report once per file, not twice per line by Chemaclass · Pull Request #1097 · 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
## Unreleased

### Changed
- Performance: `--coverage-report-html` no longer forks twice per source line to escape it — 58.7s to 9.5s for 128 files here, with the escaping done once per file and the per-page `wc`, `basename` and `pwd` calls replaced by parameter expansions (#1096)
- Performance: the coverage report picks its colours without a subshell per file and per function — the text report over 128 files went from 387ms to 318ms, and 4042ms to 3116ms with `BASHUNIT_COVERAGE_SHOW_FUNCTIONS` on (#1092)

## [0.47.0](https://github.com/TypedDevs/bashunit/compare/0.46.0...0.47.0) - 2026-08-13
Expand Down
30 changes: 20 additions & 10 deletions src/coverage/html_file.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ function bashunit::coverage::generate_file_html() {
local file="$1"
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
local display_file="${file#"$PWD"/}"
local executable hit pct class stats
stats=$(bashunit::coverage::get_cached_stats "$file")
bashunit::coverage::split_stats "$stats"
Expand All@@ -27,6 +27,16 @@ function bashunit::coverage::generate_file_html() {
((++_fli))
done <"$file"

# And their escaped form, in ONE awk pass for the whole file. Escaping per
# line cost a command substitution and a sed each -- about 22,000 processes
# for this repo, 58.7s of HTML report (#1096).
local -a escaped_lines=()
local _eli=0 _el
while IFS= read -r _el || [ -n "$_el" ]; do
escaped_lines[_eli]="$_el"
((++_eli))
done < <(bashunit::coverage::html_escape_file "$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.0 compatibility (no associative arrays)
Expand All@@ -53,8 +63,7 @@ function bashunit::coverage::generate_file_html() {
done < <(bashunit::coverage::get_all_line_tests "$file")

# Count total lines and functions
local total_lines
total_lines=$(wc -l <"$file" | tr -d ' ')
local total_lines="${#file_lines[@]}"
local non_executable=$((total_lines - executable))

{
Expand All@@ -65,7 +74,7 @@ function bashunit::coverage::generate_file_html() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
EOF
echo " <title>$(basename "$display_file") | Coverage Report</title>"
echo " <title>${display_file##*/} | Coverage Report</title>"
cat <<'EOF'
<style>
:root {
Expand DownExpand Up@@ -204,7 +213,7 @@ EOF
<a href="../index.html" class="back-btn">← Back to Overview</a>
<div class="file-title">
EOF
echo " <span class=\"file-name\">$(basename "$display_file")</span>"
echo " <span class=\"file-name\">${display_file##*/}</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -306,7 +315,10 @@ EOF
done

local fn_pct fn_class row_class
fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
case "$fn_class" in
Expand DownExpand Up@@ -354,8 +366,7 @@ EOF
for line in "${file_lines[@]}"; do
((++lineno))

local escaped_line
escaped_line=$(bashunit::coverage::html_escape "$line")
local escaped_line="${escaped_lines[$((lineno - 1))]:-}"

local row_class=""
local hits_display=""
Expand All@@ -375,8 +386,7 @@ EOF
local test_file test_fn
while IFS=':' read -r test_file test_fn; do
[ -z "$test_file" ] && continue
local short_file
short_file=$(basename "$test_file")
local short_file="${test_file##*/}"
tooltip_html="$tooltip_html<li><span class=\"hits-tooltip-file\">${short_file}</span>:<span class=\"hits-tooltip-fn\">${test_fn}</span></li>"
done <<<"$test_info"
tooltip_html="$tooltip_html</ul></div>"
Expand Down
2 changes: 1 addition & 1 deletion src/coverage/html_index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,7 +325,7 @@ EOF
echo " <tr onclick=\"window.location='files/${safe_filename}.html'\">"
echo " <td>"
echo " <div class=\"file-info\">"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">$(basename "$display_file")</a>"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">${display_file##*/}</a>"
echo " <div class=\"file-path\">./${display_file}</div>"
echo " </div>"
echo " </td>"
Expand Down
35 changes: 33 additions & 2 deletions src/coverage/report_html.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,44 @@

# HTML coverage report: orchestration and shared helpers.

# Escape HTML special characters
# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings)
# Escape HTML special characters.
#
# This cannot be `${text//&/&amp;}`. Bash 5.2 made a bare `&` in the
# REPLACEMENT mean "the matched text", so `${text//</&lt;}` yields `<lt;`
# there, while escaping it as `\&` to satisfy 5.2 emits a literal backslash on
# 3.2. No single pattern-substitution form is right across the supported range,
# and both failure modes are silent, so the escaping goes through a tool with
# stable semantics.
function bashunit::coverage::html_escape() {
local text="$1"
printf "%s" "$text" | sed "s/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g"
}

# The same escaping for a whole file, one awk pass, emitting one escaped line
# per source line in order.
#
# The per-line call above cost a command substitution AND a `sed` for every
# line of every page: about 22,000 processes for this repo, which is why an
# HTML report took 58.7s (#1096). In awk the replacement metacharacter is `&`
# too, hence the `\&` in each replacement.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE='
{
gsub(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
print
}
'

##
# Escapes every line of $1, one line of output per line of input.
# Arguments: $1 - source file
##
function bashunit::coverage::html_escape_file() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE" "$1"
}

function bashunit::coverage::report_html() {
local output_dir="${1:-coverage/html}"

Expand Down
5 changes: 4 additions & 1 deletion src/coverage/report_text.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,7 +266,10 @@ function bashunit::coverage::report_text_functions() {
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
bashunit::coverage::color_to_slot "$fn_class"
Expand Down
113 changes: 113 additions & 0 deletions tests/acceptance/bashunit_html_forks_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for the HTML report.
#
# Every source line it prints has to be HTML-escaped, and that escaping used to
# be a command substitution plus a `sed` PER LINE: about 22,000 processes for
# this repo and 58.7s of report (#1096). It is one awk pass per file now.
#
# The budget is expressed as "does not grow with the number of source lines",
# the same property tests/acceptance/bashunit_coverage_forks_test.sh pins for
# the classifier, because that is what was fixed and it needs no retuning when
# an unrelated `sed` appears elsewhere.

# Writes a fixture pair into $1: a test file plus a source file of $2 body units,
# each unit holding characters the escaper has to rewrite.
function _html_fixture() {
local dir="$1"
local units="$2"

{
echo '#!/usr/bin/env bash'
echo 'function covered_fn() {'
echo ' local total=0'
local i
for i in $(seq 1 "$units"); do
echo " # a & b <tag> $i"
echo " total=\$((total + $i))"
echo " printf '%s' \"<x> & <y>\""
done
echo ' echo "$total"'
echo '}'
} >"$dir/libhtml.sh"

{
echo "source \"$dir/libhtml.sh\""
echo 'function test_covers_source() { assert_not_empty "$(covered_fn)"; }'
} >"$dir/html_forks_test.sh"
}

# Runs an HTML coverage report with a `sed` PATH shim and echoes the fork count.
function _count_sed_forks_for_html() {
local dir="$1"
local real_sed="$2"
local count_file="$dir/sed_count"

{
echo '#!/usr/bin/env bash'
echo "echo x >> \"$count_file\""
echo "exec \"$real_sed\" \"\$@\""
} >"$dir/sed"
chmod +x "$dir/sed"
: >"$count_file"

PATH="$dir:$PATH" \
BASHUNIT_COVERAGE_PATHS="$dir" \
./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

local forks=0
if [ -f "$count_file" ]; then
forks="$(grep -c . "$count_file" || true)"
fi
echo "$forks"
}

function test_the_html_report_does_not_fork_sed_per_source_line() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local real_sed
real_sed="$(command -v sed)"

# Canonicalise: bashunit::temp_dir can yield a doubled slash and
# BASHUNIT_COVERAGE_PATHS is prefix-matched against canonicalised paths, so a
# mismatch would track nothing and the census would measure an empty run.
local small_dir large_dir
small_dir="$(cd "$(bashunit::temp_dir)" && pwd)"
large_dir="$(cd "$(bashunit::temp_dir)" && pwd)"

# 3 lines per unit: the large fixture has ~120 more lines to escape, which
# used to cost one sed each.
_html_fixture "$small_dir" 2
_html_fixture "$large_dir" 42

local small_forks large_forks
small_forks="$(_count_sed_forks_for_html "$small_dir" "$real_sed")"
large_forks="$(_count_sed_forks_for_html "$large_dir" "$real_sed")"

assert_less_than 10 "$((large_forks - small_forks))"
}

function test_the_html_report_renders_the_escaped_source() {
local dir
dir="$(cd "$(bashunit::temp_dir)" && pwd)"
_html_fixture "$dir" 1

BASHUNIT_COVERAGE_PATHS="$dir" ./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

# Pages are named after the whole mangled path, not the basename.
local page
page="$(find "$dir/html" -name '*libhtml_sh.html' | head -1)"
assert_not_empty "$page"

# The markup in the source must arrive escaped, not as markup.
local body
body="$(cat "$page")"
assert_contains "&lt;tag&gt;" "$body"
assert_contains "&amp;" "$body"
assert_not_contains '<x> & <y>' "$body"
}
70 changes: 70 additions & 0 deletions tests/unit/coverage/html_escape_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

# The HTML report escapes every source line it prints. It used to do that with
# a command substitution and a sed PER LINE -- about 22,000 processes for this
# repo, 58.7s of report (#1096) -- and now does it once per file. The two must
# produce the same bytes, or the report silently renders markup as markup.
#
# Escaping in pure Bash is not an option here, and the failure is silent both
# ways: Bash 5.2 made a bare `&` in a substitution REPLACEMENT mean "the
# matched text", so `${line//</&lt;}` yields `<lt;` on 5.2+, while writing it
# as `\&` for 5.2 emits a literal backslash on 3.2. These tests pin the output
# itself, so either mistake fails here rather than in a rendered page.

function fixture_with() { # $@ = lines
local file
file="$(bashunit::temp_file html_escape).txt"
printf '%s\n' "$@" >"$file"
echo "$file"
}

function test_the_whole_file_escaper_matches_the_per_line_one() {
local file
file="$(fixture_with \
'a & b' \
'<div class="x">' \
'x > y && z' \
'if [ "$a" -lt "$b" ]; then' \
'printf "%s\n" "<tag>"' \
'&amp; already escaped' \
'back\slash <tag>' \
'no specials here')"

local expected=""
local line
while IFS= read -r line || [ -n "$line" ]; do
expected="${expected}$(bashunit::coverage::html_escape "$line")
"
done <"$file"

assert_same "$expected" "$(bashunit::coverage::html_escape_file "$file")
"
}

# The three substitutions, spelled out. `<` must become `&lt;` and not `<lt;`,
# which is what a bare `&` in a Bash 5.2 replacement would produce.
function test_the_ampersand_is_not_a_backreference() {
local file
file="$(fixture_with '<a href="x">A & B</a>' 'x > y')"

assert_same '&lt;a href="x"&gt;A &amp; B&lt;/a&gt;
x &gt; y' "$(bashunit::coverage::html_escape_file "$file")"
}

# `&` has to be replaced before `<` and `>`, or the `&` of an already-emitted
# `&lt;` gets escaped again into `&amp;lt;`.
function test_the_ampersand_is_replaced_before_the_angle_brackets() {
local file
file="$(fixture_with '<x>')"

assert_same '&lt;x&gt;' "$(bashunit::coverage::html_escape_file "$file")"
}

function test_an_empty_line_stays_an_empty_line() {
local file
file="$(fixture_with 'a' '' 'b')"

assert_same 'a

b' "$(bashunit::coverage::html_escape_file "$file")"
}
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(coverage): escape the HTML report once per file, not twice per line by Chemaclass · Pull Request #1097 · 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
## Unreleased

### Changed
- Performance: `--coverage-report-html` no longer forks twice per source line to escape it — 58.7s to 9.5s for 128 files here, with the escaping done once per file and the per-page `wc`, `basename` and `pwd` calls replaced by parameter expansions (#1096)
- Performance: the coverage report picks its colours without a subshell per file and per function — the text report over 128 files went from 387ms to 318ms, and 4042ms to 3116ms with `BASHUNIT_COVERAGE_SHOW_FUNCTIONS` on (#1092)

## [0.47.0](https://github.com/TypedDevs/bashunit/compare/0.46.0...0.47.0) - 2026-08-13
Expand Down
30 changes: 20 additions & 10 deletions src/coverage/html_file.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ function bashunit::coverage::generate_file_html() {
local file="$1"
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
local display_file="${file#"$PWD"/}"
local executable hit pct class stats
stats=$(bashunit::coverage::get_cached_stats "$file")
bashunit::coverage::split_stats "$stats"
Expand All@@ -27,6 +27,16 @@ function bashunit::coverage::generate_file_html() {
((++_fli))
done <"$file"

# And their escaped form, in ONE awk pass for the whole file. Escaping per
# line cost a command substitution and a sed each -- about 22,000 processes
# for this repo, 58.7s of HTML report (#1096).
local -a escaped_lines=()
local _eli=0 _el
while IFS= read -r _el || [ -n "$_el" ]; do
escaped_lines[_eli]="$_el"
((++_eli))
done < <(bashunit::coverage::html_escape_file "$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.0 compatibility (no associative arrays)
Expand All@@ -53,8 +63,7 @@ function bashunit::coverage::generate_file_html() {
done < <(bashunit::coverage::get_all_line_tests "$file")

# Count total lines and functions
local total_lines
total_lines=$(wc -l <"$file" | tr -d ' ')
local total_lines="${#file_lines[@]}"
local non_executable=$((total_lines - executable))

{
Expand All@@ -65,7 +74,7 @@ function bashunit::coverage::generate_file_html() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
EOF
echo " <title>$(basename "$display_file") | Coverage Report</title>"
echo " <title>${display_file##*/} | Coverage Report</title>"
cat <<'EOF'
<style>
:root {
Expand DownExpand Up@@ -204,7 +213,7 @@ EOF
<a href="../index.html" class="back-btn">← Back to Overview</a>
<div class="file-title">
EOF
echo " <span class=\"file-name\">$(basename "$display_file")</span>"
echo " <span class=\"file-name\">${display_file##*/}</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -306,7 +315,10 @@ EOF
done

local fn_pct fn_class row_class
fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
case "$fn_class" in
Expand DownExpand Up@@ -354,8 +366,7 @@ EOF
for line in "${file_lines[@]}"; do
((++lineno))

local escaped_line
escaped_line=$(bashunit::coverage::html_escape "$line")
local escaped_line="${escaped_lines[$((lineno - 1))]:-}"

local row_class=""
local hits_display=""
Expand All@@ -375,8 +386,7 @@ EOF
local test_file test_fn
while IFS=':' read -r test_file test_fn; do
[ -z "$test_file" ] && continue
local short_file
short_file=$(basename "$test_file")
local short_file="${test_file##*/}"
tooltip_html="$tooltip_html<li><span class=\"hits-tooltip-file\">${short_file}</span>:<span class=\"hits-tooltip-fn\">${test_fn}</span></li>"
done <<<"$test_info"
tooltip_html="$tooltip_html</ul></div>"
Expand Down
2 changes: 1 addition & 1 deletion src/coverage/html_index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,7 +325,7 @@ EOF
echo " <tr onclick=\"window.location='files/${safe_filename}.html'\">"
echo " <td>"
echo " <div class=\"file-info\">"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">$(basename "$display_file")</a>"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">${display_file##*/}</a>"
echo " <div class=\"file-path\">./${display_file}</div>"
echo " </div>"
echo " </td>"
Expand Down
35 changes: 33 additions & 2 deletions src/coverage/report_html.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,44 @@

# HTML coverage report: orchestration and shared helpers.

# Escape HTML special characters
# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings)
# Escape HTML special characters.
#
# This cannot be `${text//&/&amp;}`. Bash 5.2 made a bare `&` in the
# REPLACEMENT mean "the matched text", so `${text//</&lt;}` yields `<lt;`
# there, while escaping it as `\&` to satisfy 5.2 emits a literal backslash on
# 3.2. No single pattern-substitution form is right across the supported range,
# and both failure modes are silent, so the escaping goes through a tool with
# stable semantics.
function bashunit::coverage::html_escape() {
local text="$1"
printf "%s" "$text" | sed "s/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g"
}

# The same escaping for a whole file, one awk pass, emitting one escaped line
# per source line in order.
#
# The per-line call above cost a command substitution AND a `sed` for every
# line of every page: about 22,000 processes for this repo, which is why an
# HTML report took 58.7s (#1096). In awk the replacement metacharacter is `&`
# too, hence the `\&` in each replacement.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE='
{
gsub(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
print
}
'

##
# Escapes every line of $1, one line of output per line of input.
# Arguments: $1 - source file
##
function bashunit::coverage::html_escape_file() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE" "$1"
}

function bashunit::coverage::report_html() {
local output_dir="${1:-coverage/html}"

Expand Down
5 changes: 4 additions & 1 deletion src/coverage/report_text.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,7 +266,10 @@ function bashunit::coverage::report_text_functions() {
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
bashunit::coverage::color_to_slot "$fn_class"
Expand Down
113 changes: 113 additions & 0 deletions tests/acceptance/bashunit_html_forks_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for the HTML report.
#
# Every source line it prints has to be HTML-escaped, and that escaping used to
# be a command substitution plus a `sed` PER LINE: about 22,000 processes for
# this repo and 58.7s of report (#1096). It is one awk pass per file now.
#
# The budget is expressed as "does not grow with the number of source lines",
# the same property tests/acceptance/bashunit_coverage_forks_test.sh pins for
# the classifier, because that is what was fixed and it needs no retuning when
# an unrelated `sed` appears elsewhere.

# Writes a fixture pair into $1: a test file plus a source file of $2 body units,
# each unit holding characters the escaper has to rewrite.
function _html_fixture() {
local dir="$1"
local units="$2"

{
echo '#!/usr/bin/env bash'
echo 'function covered_fn() {'
echo ' local total=0'
local i
for i in $(seq 1 "$units"); do
echo " # a & b <tag> $i"
echo " total=\$((total + $i))"
echo " printf '%s' \"<x> & <y>\""
done
echo ' echo "$total"'
echo '}'
} >"$dir/libhtml.sh"

{
echo "source \"$dir/libhtml.sh\""
echo 'function test_covers_source() { assert_not_empty "$(covered_fn)"; }'
} >"$dir/html_forks_test.sh"
}

# Runs an HTML coverage report with a `sed` PATH shim and echoes the fork count.
function _count_sed_forks_for_html() {
local dir="$1"
local real_sed="$2"
local count_file="$dir/sed_count"

{
echo '#!/usr/bin/env bash'
echo "echo x >> \"$count_file\""
echo "exec \"$real_sed\" \"\$@\""
} >"$dir/sed"
chmod +x "$dir/sed"
: >"$count_file"

PATH="$dir:$PATH" \
BASHUNIT_COVERAGE_PATHS="$dir" \
./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

local forks=0
if [ -f "$count_file" ]; then
forks="$(grep -c . "$count_file" || true)"
fi
echo "$forks"
}

function test_the_html_report_does_not_fork_sed_per_source_line() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local real_sed
real_sed="$(command -v sed)"

# Canonicalise: bashunit::temp_dir can yield a doubled slash and
# BASHUNIT_COVERAGE_PATHS is prefix-matched against canonicalised paths, so a
# mismatch would track nothing and the census would measure an empty run.
local small_dir large_dir
small_dir="$(cd "$(bashunit::temp_dir)" && pwd)"
large_dir="$(cd "$(bashunit::temp_dir)" && pwd)"

# 3 lines per unit: the large fixture has ~120 more lines to escape, which
# used to cost one sed each.
_html_fixture "$small_dir" 2
_html_fixture "$large_dir" 42

local small_forks large_forks
small_forks="$(_count_sed_forks_for_html "$small_dir" "$real_sed")"
large_forks="$(_count_sed_forks_for_html "$large_dir" "$real_sed")"

assert_less_than 10 "$((large_forks - small_forks))"
}

function test_the_html_report_renders_the_escaped_source() {
local dir
dir="$(cd "$(bashunit::temp_dir)" && pwd)"
_html_fixture "$dir" 1

BASHUNIT_COVERAGE_PATHS="$dir" ./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

# Pages are named after the whole mangled path, not the basename.
local page
page="$(find "$dir/html" -name '*libhtml_sh.html' | head -1)"
assert_not_empty "$page"

# The markup in the source must arrive escaped, not as markup.
local body
body="$(cat "$page")"
assert_contains "&lt;tag&gt;" "$body"
assert_contains "&amp;" "$body"
assert_not_contains '<x> & <y>' "$body"
}
70 changes: 70 additions & 0 deletions tests/unit/coverage/html_escape_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

# The HTML report escapes every source line it prints. It used to do that with
# a command substitution and a sed PER LINE -- about 22,000 processes for this
# repo, 58.7s of report (#1096) -- and now does it once per file. The two must
# produce the same bytes, or the report silently renders markup as markup.
#
# Escaping in pure Bash is not an option here, and the failure is silent both
# ways: Bash 5.2 made a bare `&` in a substitution REPLACEMENT mean "the
# matched text", so `${line//</&lt;}` yields `<lt;` on 5.2+, while writing it
# as `\&` for 5.2 emits a literal backslash on 3.2. These tests pin the output
# itself, so either mistake fails here rather than in a rendered page.

function fixture_with() { # $@ = lines
local file
file="$(bashunit::temp_file html_escape).txt"
printf '%s\n' "$@" >"$file"
echo "$file"
}

function test_the_whole_file_escaper_matches_the_per_line_one() {
local file
file="$(fixture_with \
'a & b' \
'<div class="x">' \
'x > y && z' \
'if [ "$a" -lt "$b" ]; then' \
'printf "%s\n" "<tag>"' \
'&amp; already escaped' \
'back\slash <tag>' \
'no specials here')"

local expected=""
local line
while IFS= read -r line || [ -n "$line" ]; do
expected="${expected}$(bashunit::coverage::html_escape "$line")
"
done <"$file"

assert_same "$expected" "$(bashunit::coverage::html_escape_file "$file")
"
}

# The three substitutions, spelled out. `<` must become `&lt;` and not `<lt;`,
# which is what a bare `&` in a Bash 5.2 replacement would produce.
function test_the_ampersand_is_not_a_backreference() {
local file
file="$(fixture_with '<a href="x">A & B</a>' 'x > y')"

assert_same '&lt;a href="x"&gt;A &amp; B&lt;/a&gt;
x &gt; y' "$(bashunit::coverage::html_escape_file "$file")"
}

# `&` has to be replaced before `<` and `>`, or the `&` of an already-emitted
# `&lt;` gets escaped again into `&amp;lt;`.
function test_the_ampersand_is_replaced_before_the_angle_brackets() {
local file
file="$(fixture_with '<x>')"

assert_same '&lt;x&gt;' "$(bashunit::coverage::html_escape_file "$file")"
}

function test_an_empty_line_stays_an_empty_line() {
local file
file="$(fixture_with 'a' '' 'b')"

assert_same 'a

b' "$(bashunit::coverage::html_escape_file "$file")"
}
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(coverage): escape the HTML report once per file, not twice per line by Chemaclass · Pull Request #1097 · 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
## Unreleased

### Changed
- Performance: `--coverage-report-html` no longer forks twice per source line to escape it — 58.7s to 9.5s for 128 files here, with the escaping done once per file and the per-page `wc`, `basename` and `pwd` calls replaced by parameter expansions (#1096)
- Performance: the coverage report picks its colours without a subshell per file and per function — the text report over 128 files went from 387ms to 318ms, and 4042ms to 3116ms with `BASHUNIT_COVERAGE_SHOW_FUNCTIONS` on (#1092)

## [0.47.0](https://github.com/TypedDevs/bashunit/compare/0.46.0...0.47.0) - 2026-08-13
Expand Down
30 changes: 20 additions & 10 deletions src/coverage/html_file.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ function bashunit::coverage::generate_file_html() {
local file="$1"
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
local display_file="${file#"$PWD"/}"
local executable hit pct class stats
stats=$(bashunit::coverage::get_cached_stats "$file")
bashunit::coverage::split_stats "$stats"
Expand All@@ -27,6 +27,16 @@ function bashunit::coverage::generate_file_html() {
((++_fli))
done <"$file"

# And their escaped form, in ONE awk pass for the whole file. Escaping per
# line cost a command substitution and a sed each -- about 22,000 processes
# for this repo, 58.7s of HTML report (#1096).
local -a escaped_lines=()
local _eli=0 _el
while IFS= read -r _el || [ -n "$_el" ]; do
escaped_lines[_eli]="$_el"
((++_eli))
done < <(bashunit::coverage::html_escape_file "$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.0 compatibility (no associative arrays)
Expand All@@ -53,8 +63,7 @@ function bashunit::coverage::generate_file_html() {
done < <(bashunit::coverage::get_all_line_tests "$file")

# Count total lines and functions
local total_lines
total_lines=$(wc -l <"$file" | tr -d ' ')
local total_lines="${#file_lines[@]}"
local non_executable=$((total_lines - executable))

{
Expand All@@ -65,7 +74,7 @@ function bashunit::coverage::generate_file_html() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
EOF
echo " <title>$(basename "$display_file") | Coverage Report</title>"
echo " <title>${display_file##*/} | Coverage Report</title>"
cat <<'EOF'
<style>
:root {
Expand DownExpand Up@@ -204,7 +213,7 @@ EOF
<a href="../index.html" class="back-btn">← Back to Overview</a>
<div class="file-title">
EOF
echo " <span class=\"file-name\">$(basename "$display_file")</span>"
echo " <span class=\"file-name\">${display_file##*/}</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -306,7 +315,10 @@ EOF
done

local fn_pct fn_class row_class
fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
case "$fn_class" in
Expand DownExpand Up@@ -354,8 +366,7 @@ EOF
for line in "${file_lines[@]}"; do
((++lineno))

local escaped_line
escaped_line=$(bashunit::coverage::html_escape "$line")
local escaped_line="${escaped_lines[$((lineno - 1))]:-}"

local row_class=""
local hits_display=""
Expand All@@ -375,8 +386,7 @@ EOF
local test_file test_fn
while IFS=':' read -r test_file test_fn; do
[ -z "$test_file" ] && continue
local short_file
short_file=$(basename "$test_file")
local short_file="${test_file##*/}"
tooltip_html="$tooltip_html<li><span class=\"hits-tooltip-file\">${short_file}</span>:<span class=\"hits-tooltip-fn\">${test_fn}</span></li>"
done <<<"$test_info"
tooltip_html="$tooltip_html</ul></div>"
Expand Down
2 changes: 1 addition & 1 deletion src/coverage/html_index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,7 +325,7 @@ EOF
echo " <tr onclick=\"window.location='files/${safe_filename}.html'\">"
echo " <td>"
echo " <div class=\"file-info\">"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">$(basename "$display_file")</a>"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">${display_file##*/}</a>"
echo " <div class=\"file-path\">./${display_file}</div>"
echo " </div>"
echo " </td>"
Expand Down
35 changes: 33 additions & 2 deletions src/coverage/report_html.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,44 @@

# HTML coverage report: orchestration and shared helpers.

# Escape HTML special characters
# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings)
# Escape HTML special characters.
#
# This cannot be `${text//&/&amp;}`. Bash 5.2 made a bare `&` in the
# REPLACEMENT mean "the matched text", so `${text//</&lt;}` yields `<lt;`
# there, while escaping it as `\&` to satisfy 5.2 emits a literal backslash on
# 3.2. No single pattern-substitution form is right across the supported range,
# and both failure modes are silent, so the escaping goes through a tool with
# stable semantics.
function bashunit::coverage::html_escape() {
local text="$1"
printf "%s" "$text" | sed "s/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g"
}

# The same escaping for a whole file, one awk pass, emitting one escaped line
# per source line in order.
#
# The per-line call above cost a command substitution AND a `sed` for every
# line of every page: about 22,000 processes for this repo, which is why an
# HTML report took 58.7s (#1096). In awk the replacement metacharacter is `&`
# too, hence the `\&` in each replacement.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE='
{
gsub(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
print
}
'

##
# Escapes every line of $1, one line of output per line of input.
# Arguments: $1 - source file
##
function bashunit::coverage::html_escape_file() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE" "$1"
}

function bashunit::coverage::report_html() {
local output_dir="${1:-coverage/html}"

Expand Down
5 changes: 4 additions & 1 deletion src/coverage/report_text.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,7 +266,10 @@ function bashunit::coverage::report_text_functions() {
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
bashunit::coverage::color_to_slot "$fn_class"
Expand Down
113 changes: 113 additions & 0 deletions tests/acceptance/bashunit_html_forks_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for the HTML report.
#
# Every source line it prints has to be HTML-escaped, and that escaping used to
# be a command substitution plus a `sed` PER LINE: about 22,000 processes for
# this repo and 58.7s of report (#1096). It is one awk pass per file now.
#
# The budget is expressed as "does not grow with the number of source lines",
# the same property tests/acceptance/bashunit_coverage_forks_test.sh pins for
# the classifier, because that is what was fixed and it needs no retuning when
# an unrelated `sed` appears elsewhere.

# Writes a fixture pair into $1: a test file plus a source file of $2 body units,
# each unit holding characters the escaper has to rewrite.
function _html_fixture() {
local dir="$1"
local units="$2"

{
echo '#!/usr/bin/env bash'
echo 'function covered_fn() {'
echo ' local total=0'
local i
for i in $(seq 1 "$units"); do
echo " # a & b <tag> $i"
echo " total=\$((total + $i))"
echo " printf '%s' \"<x> & <y>\""
done
echo ' echo "$total"'
echo '}'
} >"$dir/libhtml.sh"

{
echo "source \"$dir/libhtml.sh\""
echo 'function test_covers_source() { assert_not_empty "$(covered_fn)"; }'
} >"$dir/html_forks_test.sh"
}

# Runs an HTML coverage report with a `sed` PATH shim and echoes the fork count.
function _count_sed_forks_for_html() {
local dir="$1"
local real_sed="$2"
local count_file="$dir/sed_count"

{
echo '#!/usr/bin/env bash'
echo "echo x >> \"$count_file\""
echo "exec \"$real_sed\" \"\$@\""
} >"$dir/sed"
chmod +x "$dir/sed"
: >"$count_file"

PATH="$dir:$PATH" \
BASHUNIT_COVERAGE_PATHS="$dir" \
./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

local forks=0
if [ -f "$count_file" ]; then
forks="$(grep -c . "$count_file" || true)"
fi
echo "$forks"
}

function test_the_html_report_does_not_fork_sed_per_source_line() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local real_sed
real_sed="$(command -v sed)"

# Canonicalise: bashunit::temp_dir can yield a doubled slash and
# BASHUNIT_COVERAGE_PATHS is prefix-matched against canonicalised paths, so a
# mismatch would track nothing and the census would measure an empty run.
local small_dir large_dir
small_dir="$(cd "$(bashunit::temp_dir)" && pwd)"
large_dir="$(cd "$(bashunit::temp_dir)" && pwd)"

# 3 lines per unit: the large fixture has ~120 more lines to escape, which
# used to cost one sed each.
_html_fixture "$small_dir" 2
_html_fixture "$large_dir" 42

local small_forks large_forks
small_forks="$(_count_sed_forks_for_html "$small_dir" "$real_sed")"
large_forks="$(_count_sed_forks_for_html "$large_dir" "$real_sed")"

assert_less_than 10 "$((large_forks - small_forks))"
}

function test_the_html_report_renders_the_escaped_source() {
local dir
dir="$(cd "$(bashunit::temp_dir)" && pwd)"
_html_fixture "$dir" 1

BASHUNIT_COVERAGE_PATHS="$dir" ./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

# Pages are named after the whole mangled path, not the basename.
local page
page="$(find "$dir/html" -name '*libhtml_sh.html' | head -1)"
assert_not_empty "$page"

# The markup in the source must arrive escaped, not as markup.
local body
body="$(cat "$page")"
assert_contains "&lt;tag&gt;" "$body"
assert_contains "&amp;" "$body"
assert_not_contains '<x> & <y>' "$body"
}
70 changes: 70 additions & 0 deletions tests/unit/coverage/html_escape_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

# The HTML report escapes every source line it prints. It used to do that with
# a command substitution and a sed PER LINE -- about 22,000 processes for this
# repo, 58.7s of report (#1096) -- and now does it once per file. The two must
# produce the same bytes, or the report silently renders markup as markup.
#
# Escaping in pure Bash is not an option here, and the failure is silent both
# ways: Bash 5.2 made a bare `&` in a substitution REPLACEMENT mean "the
# matched text", so `${line//</&lt;}` yields `<lt;` on 5.2+, while writing it
# as `\&` for 5.2 emits a literal backslash on 3.2. These tests pin the output
# itself, so either mistake fails here rather than in a rendered page.

function fixture_with() { # $@ = lines
local file
file="$(bashunit::temp_file html_escape).txt"
printf '%s\n' "$@" >"$file"
echo "$file"
}

function test_the_whole_file_escaper_matches_the_per_line_one() {
local file
file="$(fixture_with \
'a & b' \
'<div class="x">' \
'x > y && z' \
'if [ "$a" -lt "$b" ]; then' \
'printf "%s\n" "<tag>"' \
'&amp; already escaped' \
'back\slash <tag>' \
'no specials here')"

local expected=""
local line
while IFS= read -r line || [ -n "$line" ]; do
expected="${expected}$(bashunit::coverage::html_escape "$line")
"
done <"$file"

assert_same "$expected" "$(bashunit::coverage::html_escape_file "$file")
"
}

# The three substitutions, spelled out. `<` must become `&lt;` and not `<lt;`,
# which is what a bare `&` in a Bash 5.2 replacement would produce.
function test_the_ampersand_is_not_a_backreference() {
local file
file="$(fixture_with '<a href="x">A & B</a>' 'x > y')"

assert_same '&lt;a href="x"&gt;A &amp; B&lt;/a&gt;
x &gt; y' "$(bashunit::coverage::html_escape_file "$file")"
}

# `&` has to be replaced before `<` and `>`, or the `&` of an already-emitted
# `&lt;` gets escaped again into `&amp;lt;`.
function test_the_ampersand_is_replaced_before_the_angle_brackets() {
local file
file="$(fixture_with '<x>')"

assert_same '&lt;x&gt;' "$(bashunit::coverage::html_escape_file "$file")"
}

function test_an_empty_line_stays_an_empty_line() {
local file
file="$(fixture_with 'a' '' 'b')"

assert_same 'a

b' "$(bashunit::coverage::html_escape_file "$file")"
}
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(coverage): escape the HTML report once per file, not twice per line by Chemaclass · Pull Request #1097 · 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
## Unreleased

### Changed
- Performance: `--coverage-report-html` no longer forks twice per source line to escape it — 58.7s to 9.5s for 128 files here, with the escaping done once per file and the per-page `wc`, `basename` and `pwd` calls replaced by parameter expansions (#1096)
- Performance: the coverage report picks its colours without a subshell per file and per function — the text report over 128 files went from 387ms to 318ms, and 4042ms to 3116ms with `BASHUNIT_COVERAGE_SHOW_FUNCTIONS` on (#1092)

## [0.47.0](https://github.com/TypedDevs/bashunit/compare/0.46.0...0.47.0) - 2026-08-13
Expand Down
30 changes: 20 additions & 10 deletions src/coverage/html_file.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ function bashunit::coverage::generate_file_html() {
local file="$1"
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
local display_file="${file#"$PWD"/}"
local executable hit pct class stats
stats=$(bashunit::coverage::get_cached_stats "$file")
bashunit::coverage::split_stats "$stats"
Expand All@@ -27,6 +27,16 @@ function bashunit::coverage::generate_file_html() {
((++_fli))
done <"$file"

# And their escaped form, in ONE awk pass for the whole file. Escaping per
# line cost a command substitution and a sed each -- about 22,000 processes
# for this repo, 58.7s of HTML report (#1096).
local -a escaped_lines=()
local _eli=0 _el
while IFS= read -r _el || [ -n "$_el" ]; do
escaped_lines[_eli]="$_el"
((++_eli))
done < <(bashunit::coverage::html_escape_file "$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.0 compatibility (no associative arrays)
Expand All@@ -53,8 +63,7 @@ function bashunit::coverage::generate_file_html() {
done < <(bashunit::coverage::get_all_line_tests "$file")

# Count total lines and functions
local total_lines
total_lines=$(wc -l <"$file" | tr -d ' ')
local total_lines="${#file_lines[@]}"
local non_executable=$((total_lines - executable))

{
Expand All@@ -65,7 +74,7 @@ function bashunit::coverage::generate_file_html() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
EOF
echo " <title>$(basename "$display_file") | Coverage Report</title>"
echo " <title>${display_file##*/} | Coverage Report</title>"
cat <<'EOF'
<style>
:root {
Expand DownExpand Up@@ -204,7 +213,7 @@ EOF
<a href="../index.html" class="back-btn">← Back to Overview</a>
<div class="file-title">
EOF
echo " <span class=\"file-name\">$(basename "$display_file")</span>"
echo " <span class=\"file-name\">${display_file##*/}</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -306,7 +315,10 @@ EOF
done

local fn_pct fn_class row_class
fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
case "$fn_class" in
Expand DownExpand Up@@ -354,8 +366,7 @@ EOF
for line in "${file_lines[@]}"; do
((++lineno))

local escaped_line
escaped_line=$(bashunit::coverage::html_escape "$line")
local escaped_line="${escaped_lines[$((lineno - 1))]:-}"

local row_class=""
local hits_display=""
Expand All@@ -375,8 +386,7 @@ EOF
local test_file test_fn
while IFS=':' read -r test_file test_fn; do
[ -z "$test_file" ] && continue
local short_file
short_file=$(basename "$test_file")
local short_file="${test_file##*/}"
tooltip_html="$tooltip_html<li><span class=\"hits-tooltip-file\">${short_file}</span>:<span class=\"hits-tooltip-fn\">${test_fn}</span></li>"
done <<<"$test_info"
tooltip_html="$tooltip_html</ul></div>"
Expand Down
2 changes: 1 addition & 1 deletion src/coverage/html_index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,7 +325,7 @@ EOF
echo " <tr onclick=\"window.location='files/${safe_filename}.html'\">"
echo " <td>"
echo " <div class=\"file-info\">"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">$(basename "$display_file")</a>"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">${display_file##*/}</a>"
echo " <div class=\"file-path\">./${display_file}</div>"
echo " </div>"
echo " </td>"
Expand Down
35 changes: 33 additions & 2 deletions src/coverage/report_html.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,44 @@

# HTML coverage report: orchestration and shared helpers.

# Escape HTML special characters
# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings)
# Escape HTML special characters.
#
# This cannot be `${text//&/&amp;}`. Bash 5.2 made a bare `&` in the
# REPLACEMENT mean "the matched text", so `${text//</&lt;}` yields `<lt;`
# there, while escaping it as `\&` to satisfy 5.2 emits a literal backslash on
# 3.2. No single pattern-substitution form is right across the supported range,
# and both failure modes are silent, so the escaping goes through a tool with
# stable semantics.
function bashunit::coverage::html_escape() {
local text="$1"
printf "%s" "$text" | sed "s/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g"
}

# The same escaping for a whole file, one awk pass, emitting one escaped line
# per source line in order.
#
# The per-line call above cost a command substitution AND a `sed` for every
# line of every page: about 22,000 processes for this repo, which is why an
# HTML report took 58.7s (#1096). In awk the replacement metacharacter is `&`
# too, hence the `\&` in each replacement.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE='
{
gsub(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
print
}
'

##
# Escapes every line of $1, one line of output per line of input.
# Arguments: $1 - source file
##
function bashunit::coverage::html_escape_file() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE" "$1"
}

function bashunit::coverage::report_html() {
local output_dir="${1:-coverage/html}"

Expand Down
5 changes: 4 additions & 1 deletion src/coverage/report_text.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,7 +266,10 @@ function bashunit::coverage::report_text_functions() {
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
bashunit::coverage::color_to_slot "$fn_class"
Expand Down
113 changes: 113 additions & 0 deletions tests/acceptance/bashunit_html_forks_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for the HTML report.
#
# Every source line it prints has to be HTML-escaped, and that escaping used to
# be a command substitution plus a `sed` PER LINE: about 22,000 processes for
# this repo and 58.7s of report (#1096). It is one awk pass per file now.
#
# The budget is expressed as "does not grow with the number of source lines",
# the same property tests/acceptance/bashunit_coverage_forks_test.sh pins for
# the classifier, because that is what was fixed and it needs no retuning when
# an unrelated `sed` appears elsewhere.

# Writes a fixture pair into $1: a test file plus a source file of $2 body units,
# each unit holding characters the escaper has to rewrite.
function _html_fixture() {
local dir="$1"
local units="$2"

{
echo '#!/usr/bin/env bash'
echo 'function covered_fn() {'
echo ' local total=0'
local i
for i in $(seq 1 "$units"); do
echo " # a & b <tag> $i"
echo " total=\$((total + $i))"
echo " printf '%s' \"<x> & <y>\""
done
echo ' echo "$total"'
echo '}'
} >"$dir/libhtml.sh"

{
echo "source \"$dir/libhtml.sh\""
echo 'function test_covers_source() { assert_not_empty "$(covered_fn)"; }'
} >"$dir/html_forks_test.sh"
}

# Runs an HTML coverage report with a `sed` PATH shim and echoes the fork count.
function _count_sed_forks_for_html() {
local dir="$1"
local real_sed="$2"
local count_file="$dir/sed_count"

{
echo '#!/usr/bin/env bash'
echo "echo x >> \"$count_file\""
echo "exec \"$real_sed\" \"\$@\""
} >"$dir/sed"
chmod +x "$dir/sed"
: >"$count_file"

PATH="$dir:$PATH" \
BASHUNIT_COVERAGE_PATHS="$dir" \
./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

local forks=0
if [ -f "$count_file" ]; then
forks="$(grep -c . "$count_file" || true)"
fi
echo "$forks"
}

function test_the_html_report_does_not_fork_sed_per_source_line() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local real_sed
real_sed="$(command -v sed)"

# Canonicalise: bashunit::temp_dir can yield a doubled slash and
# BASHUNIT_COVERAGE_PATHS is prefix-matched against canonicalised paths, so a
# mismatch would track nothing and the census would measure an empty run.
local small_dir large_dir
small_dir="$(cd "$(bashunit::temp_dir)" && pwd)"
large_dir="$(cd "$(bashunit::temp_dir)" && pwd)"

# 3 lines per unit: the large fixture has ~120 more lines to escape, which
# used to cost one sed each.
_html_fixture "$small_dir" 2
_html_fixture "$large_dir" 42

local small_forks large_forks
small_forks="$(_count_sed_forks_for_html "$small_dir" "$real_sed")"
large_forks="$(_count_sed_forks_for_html "$large_dir" "$real_sed")"

assert_less_than 10 "$((large_forks - small_forks))"
}

function test_the_html_report_renders_the_escaped_source() {
local dir
dir="$(cd "$(bashunit::temp_dir)" && pwd)"
_html_fixture "$dir" 1

BASHUNIT_COVERAGE_PATHS="$dir" ./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

# Pages are named after the whole mangled path, not the basename.
local page
page="$(find "$dir/html" -name '*libhtml_sh.html' | head -1)"
assert_not_empty "$page"

# The markup in the source must arrive escaped, not as markup.
local body
body="$(cat "$page")"
assert_contains "&lt;tag&gt;" "$body"
assert_contains "&amp;" "$body"
assert_not_contains '<x> & <y>' "$body"
}
70 changes: 70 additions & 0 deletions tests/unit/coverage/html_escape_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

# The HTML report escapes every source line it prints. It used to do that with
# a command substitution and a sed PER LINE -- about 22,000 processes for this
# repo, 58.7s of report (#1096) -- and now does it once per file. The two must
# produce the same bytes, or the report silently renders markup as markup.
#
# Escaping in pure Bash is not an option here, and the failure is silent both
# ways: Bash 5.2 made a bare `&` in a substitution REPLACEMENT mean "the
# matched text", so `${line//</&lt;}` yields `<lt;` on 5.2+, while writing it
# as `\&` for 5.2 emits a literal backslash on 3.2. These tests pin the output
# itself, so either mistake fails here rather than in a rendered page.

function fixture_with() { # $@ = lines
local file
file="$(bashunit::temp_file html_escape).txt"
printf '%s\n' "$@" >"$file"
echo "$file"
}

function test_the_whole_file_escaper_matches_the_per_line_one() {
local file
file="$(fixture_with \
'a & b' \
'<div class="x">' \
'x > y && z' \
'if [ "$a" -lt "$b" ]; then' \
'printf "%s\n" "<tag>"' \
'&amp; already escaped' \
'back\slash <tag>' \
'no specials here')"

local expected=""
local line
while IFS= read -r line || [ -n "$line" ]; do
expected="${expected}$(bashunit::coverage::html_escape "$line")
"
done <"$file"

assert_same "$expected" "$(bashunit::coverage::html_escape_file "$file")
"
}

# The three substitutions, spelled out. `<` must become `&lt;` and not `<lt;`,
# which is what a bare `&` in a Bash 5.2 replacement would produce.
function test_the_ampersand_is_not_a_backreference() {
local file
file="$(fixture_with '<a href="x">A & B</a>' 'x > y')"

assert_same '&lt;a href="x"&gt;A &amp; B&lt;/a&gt;
x &gt; y' "$(bashunit::coverage::html_escape_file "$file")"
}

# `&` has to be replaced before `<` and `>`, or the `&` of an already-emitted
# `&lt;` gets escaped again into `&amp;lt;`.
function test_the_ampersand_is_replaced_before_the_angle_brackets() {
local file
file="$(fixture_with '<x>')"

assert_same '&lt;x&gt;' "$(bashunit::coverage::html_escape_file "$file")"
}

function test_an_empty_line_stays_an_empty_line() {
local file
file="$(fixture_with 'a' '' 'b')"

assert_same 'a

b' "$(bashunit::coverage::html_escape_file "$file")"
}
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(coverage): escape the HTML report once per file, not twice per line by Chemaclass · Pull Request #1097 · 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
## Unreleased

### Changed
- Performance: `--coverage-report-html` no longer forks twice per source line to escape it — 58.7s to 9.5s for 128 files here, with the escaping done once per file and the per-page `wc`, `basename` and `pwd` calls replaced by parameter expansions (#1096)
- Performance: the coverage report picks its colours without a subshell per file and per function — the text report over 128 files went from 387ms to 318ms, and 4042ms to 3116ms with `BASHUNIT_COVERAGE_SHOW_FUNCTIONS` on (#1092)

## [0.47.0](https://github.com/TypedDevs/bashunit/compare/0.46.0...0.47.0) - 2026-08-13
Expand Down
30 changes: 20 additions & 10 deletions src/coverage/html_file.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ function bashunit::coverage::generate_file_html() {
local file="$1"
local output_file="$2"

local display_file="${file#"$(pwd)"/}"
local display_file="${file#"$PWD"/}"
local executable hit pct class stats
stats=$(bashunit::coverage::get_cached_stats "$file")
bashunit::coverage::split_stats "$stats"
Expand All@@ -27,6 +27,16 @@ function bashunit::coverage::generate_file_html() {
((++_fli))
done <"$file"

# And their escaped form, in ONE awk pass for the whole file. Escaping per
# line cost a command substitution and a sed each -- about 22,000 processes
# for this repo, 58.7s of HTML report (#1096).
local -a escaped_lines=()
local _eli=0 _el
while IFS= read -r _el || [ -n "$_el" ]; do
escaped_lines[_eli]="$_el"
((++_eli))
done < <(bashunit::coverage::html_escape_file "$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.0 compatibility (no associative arrays)
Expand All@@ -53,8 +63,7 @@ function bashunit::coverage::generate_file_html() {
done < <(bashunit::coverage::get_all_line_tests "$file")

# Count total lines and functions
local total_lines
total_lines=$(wc -l <"$file" | tr -d ' ')
local total_lines="${#file_lines[@]}"
local non_executable=$((total_lines - executable))

{
Expand All@@ -65,7 +74,7 @@ function bashunit::coverage::generate_file_html() {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
EOF
echo " <title>$(basename "$display_file") | Coverage Report</title>"
echo " <title>${display_file##*/} | Coverage Report</title>"
cat <<'EOF'
<style>
:root {
Expand DownExpand Up@@ -204,7 +213,7 @@ EOF
<a href="../index.html" class="back-btn">← Back to Overview</a>
<div class="file-title">
EOF
echo " <span class=\"file-name\">$(basename "$display_file")</span>"
echo " <span class=\"file-name\">${display_file##*/}</span>"
cat <<'EOF'
</div>
</div>
Expand DownExpand Up@@ -306,7 +315,10 @@ EOF
done

local fn_pct fn_class row_class
fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
case "$fn_class" in
Expand DownExpand Up@@ -354,8 +366,7 @@ EOF
for line in "${file_lines[@]}"; do
((++lineno))

local escaped_line
escaped_line=$(bashunit::coverage::html_escape "$line")
local escaped_line="${escaped_lines[$((lineno - 1))]:-}"

local row_class=""
local hits_display=""
Expand All@@ -375,8 +386,7 @@ EOF
local test_file test_fn
while IFS=':' read -r test_file test_fn; do
[ -z "$test_file" ] && continue
local short_file
short_file=$(basename "$test_file")
local short_file="${test_file##*/}"
tooltip_html="$tooltip_html<li><span class=\"hits-tooltip-file\">${short_file}</span>:<span class=\"hits-tooltip-fn\">${test_fn}</span></li>"
done <<<"$test_info"
tooltip_html="$tooltip_html</ul></div>"
Expand Down
2 changes: 1 addition & 1 deletion src/coverage/html_index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,7 +325,7 @@ EOF
echo " <tr onclick=\"window.location='files/${safe_filename}.html'\">"
echo " <td>"
echo " <div class=\"file-info\">"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">$(basename "$display_file")</a>"
echo " <a href=\"files/${safe_filename}.html\" class=\"file-name\">${display_file##*/}</a>"
echo " <div class=\"file-path\">./${display_file}</div>"
echo " </div>"
echo " </td>"
Expand Down
35 changes: 33 additions & 2 deletions src/coverage/report_html.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,44 @@

# HTML coverage report: orchestration and shared helpers.

# Escape HTML special characters
# Uses sed for cross-version bash compatibility (bash 3.2 vs 4.4+ handle & differently in replacement strings)
# Escape HTML special characters.
#
# This cannot be `${text//&/&amp;}`. Bash 5.2 made a bare `&` in the
# REPLACEMENT mean "the matched text", so `${text//</&lt;}` yields `<lt;`
# there, while escaping it as `\&` to satisfy 5.2 emits a literal backslash on
# 3.2. No single pattern-substitution form is right across the supported range,
# and both failure modes are silent, so the escaping goes through a tool with
# stable semantics.
function bashunit::coverage::html_escape() {
local text="$1"
printf "%s" "$text" | sed "s/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g"
}

# The same escaping for a whole file, one awk pass, emitting one escaped line
# per source line in order.
#
# The per-line call above cost a command substitution AND a `sed` for every
# line of every page: about 22,000 processes for this repo, which is why an
# HTML report took 58.7s (#1096). In awk the replacement metacharacter is `&`
# too, hence the `\&` in each replacement.
# shellcheck disable=SC2016
_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE='
{
gsub(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
print
}
'

##
# Escapes every line of $1, one line of output per line of input.
# Arguments: $1 - source file
##
function bashunit::coverage::html_escape_file() {
env LC_ALL=C "$AWK" "$_BASHUNIT_COVERAGE_AWK_HTML_ESCAPE" "$1"
}

function bashunit::coverage::report_html() {
local output_dir="${1:-coverage/html}"

Expand Down
5 changes: 4 additions & 1 deletion src/coverage/report_text.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,7 +266,10 @@ function bashunit::coverage::report_text_functions() {
[ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[$ln]:-0}" -gt 0 ] && fn_hit=$((fn_hit + 1))
done

fn_pct=$(bashunit::coverage::calculate_percentage "$fn_hit" "$fn_executable")
fn_pct=0
if [ "$fn_executable" -gt 0 ]; then
fn_pct=$((fn_hit * 100 / fn_executable))
fi
bashunit::coverage::class_to_slot "$fn_pct"
fn_class="$_BASHUNIT_COVERAGE_CLASS_OUT"
bashunit::coverage::color_to_slot "$fn_class"
Expand Down
113 changes: 113 additions & 0 deletions tests/acceptance/bashunit_html_forks_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail

# Regression guard for the HTML report.
#
# Every source line it prints has to be HTML-escaped, and that escaping used to
# be a command substitution plus a `sed` PER LINE: about 22,000 processes for
# this repo and 58.7s of report (#1096). It is one awk pass per file now.
#
# The budget is expressed as "does not grow with the number of source lines",
# the same property tests/acceptance/bashunit_coverage_forks_test.sh pins for
# the classifier, because that is what was fixed and it needs no retuning when
# an unrelated `sed` appears elsewhere.

# Writes a fixture pair into $1: a test file plus a source file of $2 body units,
# each unit holding characters the escaper has to rewrite.
function _html_fixture() {
local dir="$1"
local units="$2"

{
echo '#!/usr/bin/env bash'
echo 'function covered_fn() {'
echo ' local total=0'
local i
for i in $(seq 1 "$units"); do
echo " # a & b <tag> $i"
echo " total=\$((total + $i))"
echo " printf '%s' \"<x> & <y>\""
done
echo ' echo "$total"'
echo '}'
} >"$dir/libhtml.sh"

{
echo "source \"$dir/libhtml.sh\""
echo 'function test_covers_source() { assert_not_empty "$(covered_fn)"; }'
} >"$dir/html_forks_test.sh"
}

# Runs an HTML coverage report with a `sed` PATH shim and echoes the fork count.
function _count_sed_forks_for_html() {
local dir="$1"
local real_sed="$2"
local count_file="$dir/sed_count"

{
echo '#!/usr/bin/env bash'
echo "echo x >> \"$count_file\""
echo "exec \"$real_sed\" \"\$@\""
} >"$dir/sed"
chmod +x "$dir/sed"
: >"$count_file"

PATH="$dir:$PATH" \
BASHUNIT_COVERAGE_PATHS="$dir" \
./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

local forks=0
if [ -f "$count_file" ]; then
forks="$(grep -c . "$count_file" || true)"
fi
echo "$forks"
}

function test_the_html_report_does_not_fork_sed_per_source_line() {
if bashunit::check_os::is_windows; then
bashunit::skip "PATH shims are unreliable under Git Bash" && return
fi

local real_sed
real_sed="$(command -v sed)"

# Canonicalise: bashunit::temp_dir can yield a doubled slash and
# BASHUNIT_COVERAGE_PATHS is prefix-matched against canonicalised paths, so a
# mismatch would track nothing and the census would measure an empty run.
local small_dir large_dir
small_dir="$(cd "$(bashunit::temp_dir)" && pwd)"
large_dir="$(cd "$(bashunit::temp_dir)" && pwd)"

# 3 lines per unit: the large fixture has ~120 more lines to escape, which
# used to cost one sed each.
_html_fixture "$small_dir" 2
_html_fixture "$large_dir" 42

local small_forks large_forks
small_forks="$(_count_sed_forks_for_html "$small_dir" "$real_sed")"
large_forks="$(_count_sed_forks_for_html "$large_dir" "$real_sed")"

assert_less_than 10 "$((large_forks - small_forks))"
}

function test_the_html_report_renders_the_escaped_source() {
local dir
dir="$(cd "$(bashunit::temp_dir)" && pwd)"
_html_fixture "$dir" 1

BASHUNIT_COVERAGE_PATHS="$dir" ./bashunit --no-parallel --coverage \
--coverage-report-html "$dir/html" "$dir/html_forks_test.sh" >/dev/null 2>&1 || true

# Pages are named after the whole mangled path, not the basename.
local page
page="$(find "$dir/html" -name '*libhtml_sh.html' | head -1)"
assert_not_empty "$page"

# The markup in the source must arrive escaped, not as markup.
local body
body="$(cat "$page")"
assert_contains "&lt;tag&gt;" "$body"
assert_contains "&amp;" "$body"
assert_not_contains '<x> & <y>' "$body"
}
70 changes: 70 additions & 0 deletions tests/unit/coverage/html_escape_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

# The HTML report escapes every source line it prints. It used to do that with
# a command substitution and a sed PER LINE -- about 22,000 processes for this
# repo, 58.7s of report (#1096) -- and now does it once per file. The two must
# produce the same bytes, or the report silently renders markup as markup.
#
# Escaping in pure Bash is not an option here, and the failure is silent both
# ways: Bash 5.2 made a bare `&` in a substitution REPLACEMENT mean "the
# matched text", so `${line//</&lt;}` yields `<lt;` on 5.2+, while writing it
# as `\&` for 5.2 emits a literal backslash on 3.2. These tests pin the output
# itself, so either mistake fails here rather than in a rendered page.

function fixture_with() { # $@ = lines
local file
file="$(bashunit::temp_file html_escape).txt"
printf '%s\n' "$@" >"$file"
echo "$file"
}

function test_the_whole_file_escaper_matches_the_per_line_one() {
local file
file="$(fixture_with \
'a & b' \
'<div class="x">' \
'x > y && z' \
'if [ "$a" -lt "$b" ]; then' \
'printf "%s\n" "<tag>"' \
'&amp; already escaped' \
'back\slash <tag>' \
'no specials here')"

local expected=""
local line
while IFS= read -r line || [ -n "$line" ]; do
expected="${expected}$(bashunit::coverage::html_escape "$line")
"
done <"$file"

assert_same "$expected" "$(bashunit::coverage::html_escape_file "$file")
"
}

# The three substitutions, spelled out. `<` must become `&lt;` and not `<lt;`,
# which is what a bare `&` in a Bash 5.2 replacement would produce.
function test_the_ampersand_is_not_a_backreference() {
local file
file="$(fixture_with '<a href="x">A & B</a>' 'x > y')"

assert_same '&lt;a href="x"&gt;A &amp; B&lt;/a&gt;
x &gt; y' "$(bashunit::coverage::html_escape_file "$file")"
}

# `&` has to be replaced before `<` and `>`, or the `&` of an already-emitted
# `&lt;` gets escaped again into `&amp;lt;`.
function test_the_ampersand_is_replaced_before_the_angle_brackets() {
local file
file="$(fixture_with '<x>')"

assert_same '&lt;x&gt;' "$(bashunit::coverage::html_escape_file "$file")"
}

function test_an_empty_line_stays_an_empty_line() {
local file
file="$(fixture_with 'a' '' 'b')"

assert_same 'a

b' "$(bashunit::coverage::html_escape_file "$file")"
}
Loading