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" -} - -# 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 " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " - echo "
Total TestsPassedFailedIncompleteSkippedSnapshotTime (ms)
${#_BASHUNIT_REPORTS_TEST_NAMES[@]}$test_passed$tests_failed$tests_incomplete$tests_skipped$tests_snapshot$time
" - 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 " " - echo " " - echo " " - echo " " - echo " " - echo " " - current_file="$file" - fi - echo " " - echo " " - echo " " - echo " " - echo " " - done <"$temp_file" - - # Close the last table - if [ -n "$current_file" ]; then - echo " " - echo "
Test NameStatusTime (ms)
$name$status$test_time
" - 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 " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo "
Total TestsPassedFailedIncompleteSkippedSnapshotTime (ms)
${#_BASHUNIT_REPORTS_TEST_NAMES[@]}$test_passed$tests_failed$tests_incomplete$tests_skipped$tests_snapshot$time
" + 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 " " + echo " " + echo " " + echo " " + echo " " + echo " " + current_file="$file" + fi + echo " " + echo " " + echo " " + echo " " + echo " " + done <"$temp_file" + + # Close the last table + if [ -n "$current_file" ]; then + echo " " + echo "
Test NameStatusTime (ms)
$name$status$test_time
" + 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" +} + +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" +}