diff --git a/bashunit b/bashunit
index cd482436..52c49b05 100755
--- a/bashunit
+++ b/bashunit
@@ -85,7 +85,7 @@ source "$BASHUNIT_ROOT_DIR/src/upgrade.sh"
source "$BASHUNIT_ROOT_DIR/src/watch.sh"
source "$BASHUNIT_ROOT_DIR/src/assertions.sh"
source "$BASHUNIT_ROOT_DIR/src/doc.sh"
-source "$BASHUNIT_ROOT_DIR/src/reports.sh"
+source "$BASHUNIT_ROOT_DIR/src/reports/index.sh"
source "$BASHUNIT_ROOT_DIR/src/rerun.sh"
source "$BASHUNIT_ROOT_DIR/src/runner/index.sh"
source "$BASHUNIT_ROOT_DIR/src/benchmark.sh"
diff --git a/src/reports.sh b/src/reports.sh
deleted file mode 100755
index b3e1df45..00000000
--- a/src/reports.sh
+++ /dev/null
@@ -1,434 +0,0 @@
-#!/usr/bin/env bash
-# shellcheck disable=SC2155
-
-_BASHUNIT_REPORTS_TEST_FILES=()
-_BASHUNIT_REPORTS_TEST_NAMES=()
-_BASHUNIT_REPORTS_TEST_STATUSES=()
-_BASHUNIT_REPORTS_TEST_DURATIONS=()
-_BASHUNIT_REPORTS_TEST_ASSERTIONS=()
-_BASHUNIT_REPORTS_TEST_FAILURES=()
-_BASHUNIT_REPORTS_TEST_LINES=()
-
-function bashunit::reports::add_test_snapshot() {
- bashunit::reports::add_test "$1" "$2" "$3" "$4" "snapshot"
-}
-
-function bashunit::reports::add_test_incomplete() {
- bashunit::reports::add_test "$1" "$2" "$3" "$4" "incomplete"
-}
-
-function bashunit::reports::add_test_skipped() {
- bashunit::reports::add_test "$1" "$2" "$3" "$4" "skipped"
-}
-
-function bashunit::reports::add_test_passed() {
- bashunit::reports::add_test "$1" "$2" "$3" "$4" "passed"
-}
-
-function bashunit::reports::add_test_risky() {
- bashunit::reports::add_test "$1" "$2" "$3" "$4" "risky"
-}
-
-function bashunit::reports::add_test_failed() {
- bashunit::reports::add_test "$1" "$2" "$3" "$4" "failed" "$5"
-}
-
-# Returns 0 when any report output is requested.
-function bashunit::reports::is_enabled() {
- [ -n "${BASHUNIT_LOG_JUNIT:-}" ] ||
- [ -n "${BASHUNIT_REPORT_HTML:-}" ] ||
- [ -n "${BASHUNIT_LOG_GHA:-}" ] ||
- [ -n "${BASHUNIT_REPORT_TAP:-}" ] ||
- [ -n "${BASHUNIT_REPORT_JSON:-}" ]
-}
-
-function bashunit::reports::add_test() {
- # Skip tracking when no report output is requested
- bashunit::reports::is_enabled || return 0
-
- local file="$1"
- local test_name="$2"
- local duration="$3"
- local assertions="$4"
- local status="$5"
- local failure_message="${6:-}"
-
- # Capture the line number from the current test location ("file:line"),
- # but only when it belongs to this test's file, so a stale location from a
- # prior test never mislabels this entry.
- local line=""
- case "${_BASHUNIT_TEST_LOCATION:-}" in
- "$file":*) line="${_BASHUNIT_TEST_LOCATION##*:}" ;;
- esac
-
- _BASHUNIT_REPORTS_TEST_FILES[${#_BASHUNIT_REPORTS_TEST_FILES[@]}]="$file"
- _BASHUNIT_REPORTS_TEST_NAMES[${#_BASHUNIT_REPORTS_TEST_NAMES[@]}]="$test_name"
- _BASHUNIT_REPORTS_TEST_STATUSES[${#_BASHUNIT_REPORTS_TEST_STATUSES[@]}]="$status"
- _BASHUNIT_REPORTS_TEST_ASSERTIONS[${#_BASHUNIT_REPORTS_TEST_ASSERTIONS[@]}]="$assertions"
- _BASHUNIT_REPORTS_TEST_DURATIONS[${#_BASHUNIT_REPORTS_TEST_DURATIONS[@]}]="$duration"
- _BASHUNIT_REPORTS_TEST_FAILURES[${#_BASHUNIT_REPORTS_TEST_FAILURES[@]}]="$failure_message"
- _BASHUNIT_REPORTS_TEST_LINES[${#_BASHUNIT_REPORTS_TEST_LINES[@]}]="$line"
-}
-
-function bashunit::reports::__xml_escape() {
- local text="$1"
- # Strip ANSI escape sequences and control characters invalid in XML 1.0,
- # then escape XML special characters (& first to avoid double-escaping)
- echo "$text" \
- | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
- | tr -d '\000-\010\013\014\016-\037' \
- | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g' -e 's/"/\"/g' -e "s/'/\'/g"
-}
-
-# Escapes a string for embedding in a JSON string literal (pure Bash, no jq).
-# Strips ANSI/control chars that cannot appear inline, keeps \t\r\n as escapes.
-function bashunit::reports::__json_escape() {
- local text="$1"
- text=$(printf '%s' "$text" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' | tr -d '\000-\010\013\014\016-\037')
- # Backslash first so escapes added below are not doubled.
- text="${text//\\/\\\\}"
- text="${text//\"/\\\"}"
- text="${text//$'\t'/\\t}"
- text="${text//$'\r'/\\r}"
- text="${text//$'\n'/\\n}"
- printf '%s' "$text"
-}
-
-function bashunit::reports::generate_junit_xml() {
- local output_file="$1"
-
- local tests_skipped=$(bashunit::state::get_tests_skipped)
- local tests_incomplete=$(bashunit::state::get_tests_incomplete)
- local tests_failed=$(bashunit::state::get_tests_failed)
- local time_ms=$(bashunit::clock::total_runtime_in_milliseconds)
- local time
- # `env` rather than a bare `LC_ALL=C` prefix: C keeps awk's radix a dot for the
- # XML, and that prefix form segfaults inside `$()` on Bash 5.3 macOS (#912).
- time=$(env LC_ALL=C awk -v ms="$time_ms" 'BEGIN {printf "%.3f", ms/1000}')
-
- {
- echo ""
- echo ""
- echo " "
-
- local i
- for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
- local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}"
- local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
- local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
- local test_time_ms="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}"
- local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}"
- local test_time
- test_time=$(env LC_ALL=C awk -v ms="$test_time_ms" 'BEGIN {printf "%.3f", ms/1000}')
-
- echo " "
-
- # Add failure element for failed tests with actual failure message
- if [ "$status" = "failed" ]; then
- local escaped_message
- escaped_message=$(bashunit::reports::__xml_escape "$failure_message")
- echo " $escaped_message"
- elif [ "$status" = "risky" ]; then
- echo " "
- elif [ "$status" = "skipped" ]; then
- echo " "
- elif [ "$status" = "incomplete" ]; then
- echo " "
- fi
-
- echo " "
- done
-
- echo " "
- echo ""
- } >"$output_file"
-}
-
-##
-# Prepares a failure message for a TAP YAML diagnostic block: strips ANSI escape
-# sequences, collapses newlines to spaces and doubles single quotes so the value
-# is safe inside a YAML single-quoted scalar. Bash 3.0+ compatible.
-##
-function bashunit::reports::__tap_message() {
- echo "$1" \
- | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
- | tr '\n' ' ' \
- | sed -e "s/'/''/g"
-}
-
-##
-# Writes results in TAP version 13 format (https://testanything.org).
-# Passing/snapshot -> "ok", failed -> "not ok" with a YAML diagnostic,
-# skipped/risky -> "# SKIP", incomplete -> "# TODO".
-# Arguments: $1 - output file
-##
-function bashunit::reports::generate_report_tap() {
- local output_file="$1"
- local total="${#_BASHUNIT_REPORTS_TEST_NAMES[@]}"
-
- {
- echo "TAP version 13"
- echo "1..$total"
-
- local i seq=0
- for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
- seq=$((seq + 1))
- local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
- local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
- local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}"
-
- case "$status" in
- failed)
- echo "not ok $seq - $name"
- echo " ---"
- echo " message: '$(bashunit::reports::__tap_message "$failure_message")'"
- echo " ..."
- ;;
- skipped)
- echo "ok $seq - $name # SKIP"
- ;;
- risky)
- echo "ok $seq - $name # SKIP risky (no assertions)"
- ;;
- incomplete)
- echo "ok $seq - $name # TODO"
- ;;
- *)
- echo "ok $seq - $name"
- ;;
- esac
- done
- } >"$output_file"
-}
-
-function bashunit::reports::generate_report_json() {
- local output_file="$1"
- local total="${#_BASHUNIT_REPORTS_TEST_NAMES[@]}"
-
- local passed=0 failed=0 skipped=0 incomplete=0 duration_total=0
- local i
- for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
- duration_total=$((duration_total + ${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-0}))
- case "${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" in
- failed) failed=$((failed + 1)) ;;
- skipped) skipped=$((skipped + 1)) ;;
- incomplete) incomplete=$((incomplete + 1)) ;;
- # snapshot and risky ran without failing, so they count as passed here; the
- # per-test "status" field below preserves the exact category.
- *) passed=$((passed + 1)) ;;
- esac
- done
-
- {
- printf '{\n'
- printf ' "summary": { "total": %d, "passed": %d, "failed": %d,' \
- "$total" "$passed" "$failed"
- printf ' "skipped": %d, "incomplete": %d, "duration_ms": %d },\n' \
- "$skipped" "$incomplete" "$duration_total"
- printf ' "tests": [\n'
- local seq=0
- for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
- local file name status duration message sep
- file=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}")
- name=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}")
- status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
- duration="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-0}"
- message=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}")
- sep=","
- [ "$seq" -eq "$((total - 1))" ] && sep=""
- printf ' { "file": "%s", "name": "%s", "status": "%s", "duration_ms": %d, "message": "%s" }%s\n' \
- "$file" "$name" "$status" "$duration" "$message" "$sep"
- seq=$((seq + 1))
- done
- printf ' ]\n'
- printf '}\n'
- } >"$output_file"
-}
-
-function bashunit::reports::__gha_encode() {
- local text="$1"
- # Strip ANSI escape sequences first (one sed call)
- text=$(printf '%s' "$text" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g')
- # Percent-encode reserved chars per GHA workflow-commands spec.
- # Bash 3.0+ parameter expansion avoids extra awk/sed calls.
- # Order matters: encode '%' first so the sequences we inject stay literal.
- text="${text//%/%25}"
- text="${text//$'\r'/%0D}"
- text="${text//$'\n'/%0A}"
- printf '%s' "$text"
-}
-
-# Echoes GitHub Actions workflow-command annotations to stdout.
-# Arguments: $1 - "failed-only" to emit just errors (default: all reportable).
-function bashunit::reports::print_gha_annotations() {
- local only="${1:-all}"
-
- local i
- for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
- local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}"
- local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
- local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
- local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}"
- local line="${_BASHUNIT_REPORTS_TEST_LINES[$i]:-}"
- local level="" message=""
-
- case "$status" in
- failed)
- level="error"
- message="$failure_message"
- ;;
- risky)
- level="warning"
- message="Test has no assertions (risky)"
- ;;
- incomplete)
- level="notice"
- message="Test incomplete"
- ;;
- *)
- continue
- ;;
- esac
-
- if [ "$only" = "failed-only" ] && [ "$status" != "failed" ]; then
- continue
- fi
-
- local location="file=${file}"
- if [ -n "$line" ]; then
- location="${location},line=${line}"
- fi
-
- local encoded_message
- encoded_message=$(bashunit::reports::__gha_encode "$message")
- echo "::${level} ${location},title=${name}::${encoded_message}"
- done
-}
-
-function bashunit::reports::generate_gha_log() {
- local output_file="$1"
-
- bashunit::reports::print_gha_annotations all >"$output_file"
-}
-
-function bashunit::reports::generate_report_html() {
- local output_file="$1"
-
- local test_passed=$(bashunit::state::get_tests_passed)
- local tests_skipped=$(bashunit::state::get_tests_skipped)
- local tests_incomplete=$(bashunit::state::get_tests_incomplete)
- local tests_snapshot=$(bashunit::state::get_tests_snapshot)
- local tests_failed=$(bashunit::state::get_tests_failed)
- local time=$(bashunit::clock::total_runtime_in_milliseconds)
-
- # Temporary file to store test cases by file (use mktemp for parallel safety)
- local temp_file
- temp_file=$(mktemp "${TMPDIR:-/tmp}/bashunit-report.XXXXXX")
-
- # Collect test cases by file
- : >"$temp_file" # Clear temp file if it exists
- local i
- for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
- local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}"
- local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
- local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
- local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}"
- local test_case="$file|$name|$status|$test_time"
-
- echo "$test_case" >>"$temp_file"
- done
-
- {
- echo ""
- echo ""
- echo "
"
- echo " "
- echo " "
- echo " Test Report"
- echo " "
- echo ""
- echo ""
- echo " Test Report
"
- echo " "
- echo " "
- echo " "
- echo " | Total Tests | "
- echo " Passed | "
- echo " Failed | "
- echo " Incomplete | "
- echo " Skipped | "
- echo " Snapshot | "
- echo " Time (ms) | "
- echo "
"
- echo " "
- echo " "
- echo " "
- echo " | ${#_BASHUNIT_REPORTS_TEST_NAMES[@]} | "
- echo " $test_passed | "
- echo " $tests_failed | "
- echo " $tests_incomplete | "
- echo " $tests_skipped | "
- echo " $tests_snapshot | "
- echo " $time | "
- echo "
"
- echo " "
- echo "
"
- echo " Time: $time ms
"
-
- # Read the temporary file and group by file
- local current_file=""
- local file name status test_time
- while IFS='|' read -r file name status test_time; do
- if [ "$file" != "$current_file" ]; then
- if [ -n "$current_file" ]; then
- echo " "
- echo " "
- fi
- echo " File: $file
"
- echo " "
- echo " "
- echo " "
- echo " | Test Name | "
- echo " Status | "
- echo " Time (ms) | "
- echo "
"
- echo " "
- echo " "
- current_file="$file"
- fi
- echo " "
- echo " | $name | "
- echo " $status | "
- echo " $test_time | "
- echo "
"
- done <"$temp_file"
-
- # Close the last table
- if [ -n "$current_file" ]; then
- echo " "
- echo "
"
- fi
-
- echo ""
- echo ""
- } >"$output_file"
-
- # Clean up temporary file
- rm -f "$temp_file"
-}
diff --git a/src/reports/collect.sh b/src/reports/collect.sh
new file mode 100644
index 00000000..ab01c3d6
--- /dev/null
+++ b/src/reports/collect.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+
+# Collected per-test results: the shared arrays every report writer reads, and the API the runner calls to fill them.
+
+_BASHUNIT_REPORTS_TEST_FILES=()
+_BASHUNIT_REPORTS_TEST_NAMES=()
+_BASHUNIT_REPORTS_TEST_STATUSES=()
+_BASHUNIT_REPORTS_TEST_DURATIONS=()
+_BASHUNIT_REPORTS_TEST_ASSERTIONS=()
+_BASHUNIT_REPORTS_TEST_FAILURES=()
+_BASHUNIT_REPORTS_TEST_LINES=()
+
+function bashunit::reports::add_test_snapshot() {
+ bashunit::reports::add_test "$1" "$2" "$3" "$4" "snapshot"
+}
+
+function bashunit::reports::add_test_incomplete() {
+ bashunit::reports::add_test "$1" "$2" "$3" "$4" "incomplete"
+}
+
+function bashunit::reports::add_test_skipped() {
+ bashunit::reports::add_test "$1" "$2" "$3" "$4" "skipped"
+}
+
+function bashunit::reports::add_test_passed() {
+ bashunit::reports::add_test "$1" "$2" "$3" "$4" "passed"
+}
+
+function bashunit::reports::add_test_risky() {
+ bashunit::reports::add_test "$1" "$2" "$3" "$4" "risky"
+}
+
+function bashunit::reports::add_test_failed() {
+ bashunit::reports::add_test "$1" "$2" "$3" "$4" "failed" "$5"
+}
+
+# Returns 0 when any report output is requested.
+function bashunit::reports::is_enabled() {
+ [ -n "${BASHUNIT_LOG_JUNIT:-}" ] ||
+ [ -n "${BASHUNIT_REPORT_HTML:-}" ] ||
+ [ -n "${BASHUNIT_LOG_GHA:-}" ] ||
+ [ -n "${BASHUNIT_REPORT_TAP:-}" ] ||
+ [ -n "${BASHUNIT_REPORT_JSON:-}" ]
+}
+
+function bashunit::reports::add_test() {
+ # Skip tracking when no report output is requested
+ bashunit::reports::is_enabled || return 0
+
+ local file="$1"
+ local test_name="$2"
+ local duration="$3"
+ local assertions="$4"
+ local status="$5"
+ local failure_message="${6:-}"
+
+ # Capture the line number from the current test location ("file:line"),
+ # but only when it belongs to this test's file, so a stale location from a
+ # prior test never mislabels this entry.
+ local line=""
+ case "${_BASHUNIT_TEST_LOCATION:-}" in
+ "$file":*) line="${_BASHUNIT_TEST_LOCATION##*:}" ;;
+ esac
+
+ _BASHUNIT_REPORTS_TEST_FILES[${#_BASHUNIT_REPORTS_TEST_FILES[@]}]="$file"
+ _BASHUNIT_REPORTS_TEST_NAMES[${#_BASHUNIT_REPORTS_TEST_NAMES[@]}]="$test_name"
+ _BASHUNIT_REPORTS_TEST_STATUSES[${#_BASHUNIT_REPORTS_TEST_STATUSES[@]}]="$status"
+ _BASHUNIT_REPORTS_TEST_ASSERTIONS[${#_BASHUNIT_REPORTS_TEST_ASSERTIONS[@]}]="$assertions"
+ _BASHUNIT_REPORTS_TEST_DURATIONS[${#_BASHUNIT_REPORTS_TEST_DURATIONS[@]}]="$duration"
+ _BASHUNIT_REPORTS_TEST_FAILURES[${#_BASHUNIT_REPORTS_TEST_FAILURES[@]}]="$failure_message"
+ _BASHUNIT_REPORTS_TEST_LINES[${#_BASHUNIT_REPORTS_TEST_LINES[@]}]="$line"
+}
diff --git a/src/reports/gha.sh b/src/reports/gha.sh
new file mode 100644
index 00000000..82878209
--- /dev/null
+++ b/src/reports/gha.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+
+# GitHub Actions workflow-commands log writer.
+
+function bashunit::reports::__gha_encode() {
+ local text="$1"
+ # Strip ANSI escape sequences first (one sed call)
+ text=$(printf '%s' "$text" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g')
+ # Percent-encode reserved chars per GHA workflow-commands spec.
+ # Bash 3.0+ parameter expansion avoids extra awk/sed calls.
+ # Order matters: encode '%' first so the sequences we inject stay literal.
+ text="${text//%/%25}"
+ text="${text//$'\r'/%0D}"
+ text="${text//$'\n'/%0A}"
+ printf '%s' "$text"
+}
+
+# Echoes GitHub Actions workflow-command annotations to stdout.
+# Arguments: $1 - "failed-only" to emit just errors (default: all reportable).
+function bashunit::reports::print_gha_annotations() {
+ local only="${1:-all}"
+
+ local i
+ for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
+ local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}"
+ local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
+ local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
+ local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}"
+ local line="${_BASHUNIT_REPORTS_TEST_LINES[$i]:-}"
+ local level="" message=""
+
+ case "$status" in
+ failed)
+ level="error"
+ message="$failure_message"
+ ;;
+ risky)
+ level="warning"
+ message="Test has no assertions (risky)"
+ ;;
+ incomplete)
+ level="notice"
+ message="Test incomplete"
+ ;;
+ *)
+ continue
+ ;;
+ esac
+
+ if [ "$only" = "failed-only" ] && [ "$status" != "failed" ]; then
+ continue
+ fi
+
+ local location="file=${file}"
+ if [ -n "$line" ]; then
+ location="${location},line=${line}"
+ fi
+
+ local encoded_message
+ encoded_message=$(bashunit::reports::__gha_encode "$message")
+ echo "::${level} ${location},title=${name}::${encoded_message}"
+ done
+}
+
+function bashunit::reports::generate_gha_log() {
+ local output_file="$1"
+
+ bashunit::reports::print_gha_annotations all >"$output_file"
+}
diff --git a/src/reports/html.sh b/src/reports/html.sh
new file mode 100644
index 00000000..3caaa894
--- /dev/null
+++ b/src/reports/html.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC2155
+
+# HTML report writer.
+
+function bashunit::reports::generate_report_html() {
+ local output_file="$1"
+
+ local test_passed=$(bashunit::state::get_tests_passed)
+ local tests_skipped=$(bashunit::state::get_tests_skipped)
+ local tests_incomplete=$(bashunit::state::get_tests_incomplete)
+ local tests_snapshot=$(bashunit::state::get_tests_snapshot)
+ local tests_failed=$(bashunit::state::get_tests_failed)
+ local time=$(bashunit::clock::total_runtime_in_milliseconds)
+
+ # Temporary file to store test cases by file (use mktemp for parallel safety)
+ local temp_file
+ temp_file=$(mktemp "${TMPDIR:-/tmp}/bashunit-report.XXXXXX")
+
+ # Collect test cases by file
+ : >"$temp_file" # Clear temp file if it exists
+ local i
+ for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
+ local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}"
+ local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
+ local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
+ local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}"
+ local test_case="$file|$name|$status|$test_time"
+
+ echo "$test_case" >>"$temp_file"
+ done
+
+ {
+ echo ""
+ echo ""
+ echo ""
+ echo " "
+ echo " "
+ echo " Test Report"
+ echo " "
+ echo ""
+ echo ""
+ echo " Test Report
"
+ echo " "
+ echo " "
+ echo " "
+ echo " | Total Tests | "
+ echo " Passed | "
+ echo " Failed | "
+ echo " Incomplete | "
+ echo " Skipped | "
+ echo " Snapshot | "
+ echo " Time (ms) | "
+ echo "
"
+ echo " "
+ echo " "
+ echo " "
+ echo " | ${#_BASHUNIT_REPORTS_TEST_NAMES[@]} | "
+ echo " $test_passed | "
+ echo " $tests_failed | "
+ echo " $tests_incomplete | "
+ echo " $tests_skipped | "
+ echo " $tests_snapshot | "
+ echo " $time | "
+ echo "
"
+ echo " "
+ echo "
"
+ echo " Time: $time ms
"
+
+ # Read the temporary file and group by file
+ local current_file=""
+ local file name status test_time
+ while IFS='|' read -r file name status test_time; do
+ if [ "$file" != "$current_file" ]; then
+ if [ -n "$current_file" ]; then
+ echo " "
+ echo " "
+ fi
+ echo " File: $file
"
+ echo " "
+ echo " "
+ echo " "
+ echo " | Test Name | "
+ echo " Status | "
+ echo " Time (ms) | "
+ echo "
"
+ echo " "
+ echo " "
+ current_file="$file"
+ fi
+ echo " "
+ echo " | $name | "
+ echo " $status | "
+ echo " $test_time | "
+ echo "
"
+ done <"$temp_file"
+
+ # Close the last table
+ if [ -n "$current_file" ]; then
+ echo " "
+ echo "
"
+ fi
+
+ echo ""
+ echo ""
+ } >"$output_file"
+
+ # Clean up temporary file
+ rm -f "$temp_file"
+}
diff --git a/src/reports/index.sh b/src/reports/index.sh
new file mode 100644
index 00000000..45b7ea10
--- /dev/null
+++ b/src/reports/index.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+# Entry point for the src/reports/ module: only `source` lines and comments
+# belong here. build.sh emits a file's body before recursing into its `source`
+# lines, so any statement here would run before its dependencies in the built
+# binary (adrs/adr-010-src-module-directories.md).
+#
+# collect.sh holds the shared result arrays; each writer reads them and nothing
+# else, so the layering is one level deep.
+source "$BASHUNIT_ROOT_DIR/src/reports/collect.sh"
+source "$BASHUNIT_ROOT_DIR/src/reports/junit.sh"
+source "$BASHUNIT_ROOT_DIR/src/reports/tap.sh"
+source "$BASHUNIT_ROOT_DIR/src/reports/json.sh"
+source "$BASHUNIT_ROOT_DIR/src/reports/gha.sh"
+source "$BASHUNIT_ROOT_DIR/src/reports/html.sh"
diff --git a/src/reports/json.sh b/src/reports/json.sh
new file mode 100644
index 00000000..b0f6d417
--- /dev/null
+++ b/src/reports/json.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+
+# Machine-readable JSON report writer.
+
+# Escapes a string for embedding in a JSON string literal (pure Bash, no jq).
+# Strips ANSI/control chars that cannot appear inline, keeps \t\r\n as escapes.
+function bashunit::reports::__json_escape() {
+ local text="$1"
+ text=$(printf '%s' "$text" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' | tr -d '\000-\010\013\014\016-\037')
+ # Backslash first so escapes added below are not doubled.
+ text="${text//\\/\\\\}"
+ text="${text//\"/\\\"}"
+ text="${text//$'\t'/\\t}"
+ text="${text//$'\r'/\\r}"
+ text="${text//$'\n'/\\n}"
+ printf '%s' "$text"
+}
+
+function bashunit::reports::generate_report_json() {
+ local output_file="$1"
+ local total="${#_BASHUNIT_REPORTS_TEST_NAMES[@]}"
+
+ local passed=0 failed=0 skipped=0 incomplete=0 duration_total=0
+ local i
+ for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
+ duration_total=$((duration_total + ${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-0}))
+ case "${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" in
+ failed) failed=$((failed + 1)) ;;
+ skipped) skipped=$((skipped + 1)) ;;
+ incomplete) incomplete=$((incomplete + 1)) ;;
+ # snapshot and risky ran without failing, so they count as passed here; the
+ # per-test "status" field below preserves the exact category.
+ *) passed=$((passed + 1)) ;;
+ esac
+ done
+
+ {
+ printf '{\n'
+ printf ' "summary": { "total": %d, "passed": %d, "failed": %d,' \
+ "$total" "$passed" "$failed"
+ printf ' "skipped": %d, "incomplete": %d, "duration_ms": %d },\n' \
+ "$skipped" "$incomplete" "$duration_total"
+ printf ' "tests": [\n'
+ local seq=0
+ for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
+ local file name status duration message sep
+ file=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}")
+ name=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}")
+ status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
+ duration="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-0}"
+ message=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}")
+ sep=","
+ [ "$seq" -eq "$((total - 1))" ] && sep=""
+ printf ' { "file": "%s", "name": "%s", "status": "%s", "duration_ms": %d, "message": "%s" }%s\n' \
+ "$file" "$name" "$status" "$duration" "$message" "$sep"
+ seq=$((seq + 1))
+ done
+ printf ' ]\n'
+ printf '}\n'
+ } >"$output_file"
+}
diff --git a/src/reports/junit.sh b/src/reports/junit.sh
new file mode 100644
index 00000000..39f208d2
--- /dev/null
+++ b/src/reports/junit.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC2155
+
+# JUnit XML report writer.
+
+function bashunit::reports::__xml_escape() {
+ local text="$1"
+ # Strip ANSI escape sequences and control characters invalid in XML 1.0,
+ # then escape XML special characters (& first to avoid double-escaping)
+ echo "$text" \
+ | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
+ | tr -d '\000-\010\013\014\016-\037' \
+ | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g' -e 's/"/\"/g' -e "s/'/\'/g"
+}
+
+function bashunit::reports::generate_junit_xml() {
+ local output_file="$1"
+
+ local tests_skipped=$(bashunit::state::get_tests_skipped)
+ local tests_incomplete=$(bashunit::state::get_tests_incomplete)
+ local tests_failed=$(bashunit::state::get_tests_failed)
+ local time_ms=$(bashunit::clock::total_runtime_in_milliseconds)
+ local time
+ # `env` rather than a bare `LC_ALL=C` prefix: C keeps awk's radix a dot for the
+ # XML, and that prefix form segfaults inside `$()` on Bash 5.3 macOS (#912).
+ time=$(env LC_ALL=C awk -v ms="$time_ms" 'BEGIN {printf "%.3f", ms/1000}')
+
+ {
+ echo ""
+ echo ""
+ echo " "
+
+ local i
+ for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
+ local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}"
+ local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
+ local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
+ local test_time_ms="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}"
+ local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}"
+ local test_time
+ test_time=$(env LC_ALL=C awk -v ms="$test_time_ms" 'BEGIN {printf "%.3f", ms/1000}')
+
+ echo " "
+
+ # Add failure element for failed tests with actual failure message
+ if [ "$status" = "failed" ]; then
+ local escaped_message
+ escaped_message=$(bashunit::reports::__xml_escape "$failure_message")
+ echo " $escaped_message"
+ elif [ "$status" = "risky" ]; then
+ echo " "
+ elif [ "$status" = "skipped" ]; then
+ echo " "
+ elif [ "$status" = "incomplete" ]; then
+ echo " "
+ fi
+
+ echo " "
+ done
+
+ echo " "
+ echo ""
+ } >"$output_file"
+}
diff --git a/src/reports/tap.sh b/src/reports/tap.sh
new file mode 100644
index 00000000..4eac2126
--- /dev/null
+++ b/src/reports/tap.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+
+# TAP version 13 report writer.
+
+##
+# Prepares a failure message for a TAP YAML diagnostic block: strips ANSI escape
+# sequences, collapses newlines to spaces and doubles single quotes so the value
+# is safe inside a YAML single-quoted scalar. Bash 3.0+ compatible.
+##
+function bashunit::reports::__tap_message() {
+ echo "$1" \
+ | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
+ | tr '\n' ' ' \
+ | sed -e "s/'/''/g"
+}
+
+##
+# Writes results in TAP version 13 format (https://testanything.org).
+# Passing/snapshot -> "ok", failed -> "not ok" with a YAML diagnostic,
+# skipped/risky -> "# SKIP", incomplete -> "# TODO".
+# Arguments: $1 - output file
+##
+function bashunit::reports::generate_report_tap() {
+ local output_file="$1"
+ local total="${#_BASHUNIT_REPORTS_TEST_NAMES[@]}"
+
+ {
+ echo "TAP version 13"
+ echo "1..$total"
+
+ local i seq=0
+ for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
+ seq=$((seq + 1))
+ local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
+ local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
+ local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}"
+
+ case "$status" in
+ failed)
+ echo "not ok $seq - $name"
+ echo " ---"
+ echo " message: '$(bashunit::reports::__tap_message "$failure_message")'"
+ echo " ..."
+ ;;
+ skipped)
+ echo "ok $seq - $name # SKIP"
+ ;;
+ risky)
+ echo "ok $seq - $name # SKIP risky (no assertions)"
+ ;;
+ incomplete)
+ echo "ok $seq - $name # TODO"
+ ;;
+ *)
+ echo "ok $seq - $name"
+ ;;
+ esac
+ done
+ } >"$output_file"
+}