diff --git a/CHANGELOG.md b/CHANGELOG.md index 007e04d9..86b649bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Display captured test output on assertion failures when `--show-output` is enabled (#637) + ## [0.35.0](https://github.com/TypedDevs/bashunit/compare/0.34.1...0.35.0) - 2026-04-26 ### Added diff --git a/docs/command-line.md b/docs/command-line.md index f06f8a51..055a2338 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -371,11 +371,12 @@ The `--log-gha` flag writes GitHub Actions workflow commands (`::error`, `::warn > `bashunit test --show-output` > `bashunit test --no-output-on-failure` -Control whether test output (stdout/stderr) is displayed when tests fail with runtime errors. +Control whether test output (stdout/stderr) is displayed when tests fail with runtime errors or assertion failures. By default (`--show-output`), when a test fails due to a runtime error (command not found, -unbound variable, permission denied, etc.), bashunit displays the captured output in an -"Output:" section to help debug the failure. +unbound variable, permission denied, etc.) or a failed assertion after the test printed +diagnostics, bashunit displays the captured output in an "Output:" section to help debug +the failure. Use `--no-output-on-failure` to suppress this output. diff --git a/docs/configuration.md b/docs/configuration.md index 2b515ef9..fbed64ce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -424,10 +424,11 @@ BASHUNIT_NO_PROGRESS=true > `BASHUNIT_SHOW_OUTPUT_ON_FAILURE=true|false` -Display captured stdout/stderr output when tests fail with runtime errors. `true` by default. +Display captured stdout/stderr output when tests fail with runtime errors or assertion failures. `true` by default. -When a test fails due to a runtime error (command not found, unbound variable, etc.), -bashunit displays the test's output in an "Output:" section to help debug the failure. +When a test fails due to a runtime error (command not found, unbound variable, etc.) or +a failed assertion after the test printed diagnostics, bashunit displays the test's output +in an "Output:" section to help debug the failure. Similar as using `--show-output` or `--no-output-on-failure` options on the [command line](/command-line#show-output-on-failure). diff --git a/src/runner.sh b/src/runner.sh index 02b01694..8f6c0e39 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -814,7 +814,12 @@ function bashunit::runner::run_test() { if [ "$current_assertions_failed" != "$_BASHUNIT_ASSERTIONS_FAILED" ]; then bashunit::state::add_tests_failed bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$subshell_output" - bashunit::runner::write_failure_result_output "$test_file" "$fn_name" "$subshell_output" + local assertion_runtime_output + assertion_runtime_output="$( + bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$subshell_output" + )" + bashunit::runner::write_failure_result_output \ + "$test_file" "$fn_name" "$subshell_output" "$assertion_runtime_output" bashunit::internal_log "Test failed" "$label" @@ -949,6 +954,111 @@ function bashunit::runner::decode_subshell_output() { bashunit::helper::decode_base64 "$test_output_base64" } +function bashunit::runner::is_simple_progress_output() { + local output="$1" + + [ -n "$output" ] || return 1 + + local color + for color in \ + "$_BASHUNIT_COLOR_DEFAULT" \ + "$_BASHUNIT_COLOR_PASSED" \ + "$_BASHUNIT_COLOR_FAILED" \ + "$_BASHUNIT_COLOR_SKIPPED" \ + "$_BASHUNIT_COLOR_INCOMPLETE" \ + "$_BASHUNIT_COLOR_SNAPSHOT" \ + "$_BASHUNIT_COLOR_RISKY"; do + [ -n "$color" ] && output="${output//"$color"/}" + done + + local i + local char + for ((i = 0; i < ${#output}; i++)); do + char="${output:$i:1}" + case "$char" in + "." | "F" | "S" | "I" | "N" | "R" | "E" | "?") ;; + *) return 1 ;; + esac + done + + return 0 +} + +function bashunit::runner::line_starts_with_result_marker() { + local line="$1" + local marker + local -a result_markers + result_markers=( + "${_BASHUNIT_COLOR_PASSED}✓ Passed" + "${_BASHUNIT_COLOR_FAILED}✗ Failed" + "${_BASHUNIT_COLOR_FAILED}✗ Error" + "${_BASHUNIT_COLOR_SKIPPED}↷ Skipped" + "${_BASHUNIT_COLOR_INCOMPLETE}✒ Incomplete" + "${_BASHUNIT_COLOR_SNAPSHOT}✎ Snapshot" + "${_BASHUNIT_COLOR_RISKY}⚠ Risky" + "✓ Passed" + "✗ Failed" + "✗ Error" + "↷ Skipped" + "✒ Incomplete" + "✎ Snapshot" + "⚠ Risky" + ) + + for marker in "${result_markers[@]}"; do + case "$line" in + "$marker"*) return 0 ;; + esac + done + + return 1 +} + +function bashunit::runner::line_exists_in_output() { + local needle="$1" + local haystack="$2" + local line + + while IFS= read -r line || [ -n "$line" ]; do + [ "$line" = "$needle" ] && return 0 + done <<<"$haystack" + + return 1 +} + +function bashunit::runner::extract_assertion_runtime_output() { + local runtime_output="$1" + local rendered_assertion_output="$2" + local filtered_output="" + local line + + while IFS= read -r line || [ -n "$line" ]; do + if bashunit::runner::line_exists_in_output "$line" "$rendered_assertion_output"; then + continue + fi + if bashunit::runner::is_simple_progress_output "$line"; then + continue + fi + if bashunit::runner::line_starts_with_result_marker "$line"; then + continue + fi + + [ -n "$filtered_output" ] && filtered_output="$filtered_output"$'\n' + filtered_output="$filtered_output$line" + done <<<"$runtime_output" + + runtime_output="$filtered_output" + + while [ -n "$runtime_output" ]; do + case "$runtime_output" in + *$'\n') runtime_output="${runtime_output%$'\n'}" ;; + *) break ;; + esac + done + + echo "$runtime_output" +} + function bashunit::runner::parse_result() { local fn_name=$1 shift diff --git a/tests/acceptance/bashunit_show_output_on_failure_test.sh b/tests/acceptance/bashunit_show_output_on_failure_test.sh index 59dcef0e..865a1e2c 100644 --- a/tests/acceptance/bashunit_show_output_on_failure_test.sh +++ b/tests/acceptance/bashunit_show_output_on_failure_test.sh @@ -51,3 +51,23 @@ function test_show_output_flag_overrides_env() { assert_contains "Output:" "$actual" assert_contains "Debug: Starting test" "$actual" } + +function test_show_output_on_assertion_failure_enabled_by_default() { + local test_file=./tests/acceptance/fixtures/test_bashunit_show_assertion_failure_output.sh + + local actual + actual="$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file" 2>&1 || true)" + + assert_contains "Output:" "$actual" + assert_contains "function_being_tested requires at least 3 arguments." "$actual" +} + +function test_show_output_on_assertion_failure_disabled_via_flag() { + local test_file=./tests/acceptance/fixtures/test_bashunit_show_assertion_failure_output.sh + + local actual + actual="$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --no-output-on-failure "$test_file" 2>&1 || true)" + + assert_not_contains "Output:" "$actual" + assert_not_contains "function_being_tested requires at least 3 arguments." "$actual" +} diff --git a/tests/acceptance/fixtures/test_bashunit_show_assertion_failure_output.sh b/tests/acceptance/fixtures/test_bashunit_show_assertion_failure_output.sh new file mode 100644 index 00000000..d59fb5fd --- /dev/null +++ b/tests/acceptance/fixtures/test_bashunit_show_assertion_failure_output.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +function function_being_tested() { + if [ "$#" -lt 3 ]; then + echo "function_being_tested requires at least 3 arguments." >&2 + return 1 + fi + + return 0 +} + +function test_assertion_failure_with_stderr_output() { + function_being_tested 1 2 + + assert_exit_code 0 +} diff --git a/tests/unit/runner_test.sh b/tests/unit/runner_test.sh new file mode 100644 index 00000000..20b5620c --- /dev/null +++ b/tests/unit/runner_test.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +function test_extract_assertion_runtime_output_keeps_user_output() { + local runtime_output + runtime_output=$'diagnostic from stderr\n✗ Failed: Example\n Expected '\''1'\''' + local rendered_assertion_output + rendered_assertion_output=$'✗ Failed: Example\n Expected '\''1'\''' + + local actual + actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" + + assert_same "diagnostic from stderr" "$actual" +} + +function test_extract_assertion_runtime_output_ignores_bashunit_status_output_before_failure() { + local runtime_output + runtime_output=$'✒ Incomplete: Example pending\n✗ Failed: Example\n Expected '\''1'\''' + local rendered_assertion_output + rendered_assertion_output=$'✒ Incomplete: Example pending\n✗ Failed: Example\n Expected '\''1'\''' + + local actual + actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" + + assert_empty "$actual" +} + +function test_extract_assertion_runtime_output_keeps_user_output_after_status_output() { + local runtime_output + runtime_output=$'✓ Passed: Previous assertion\ndiagnostic after pass\n✗ Failed: Example' + local rendered_assertion_output + rendered_assertion_output=$'✓ Passed: Previous assertion\n✗ Failed: Example' + + local actual + actual="$(bashunit::runner::extract_assertion_runtime_output "$runtime_output" "$rendered_assertion_output")" + + assert_same "diagnostic after pass" "$actual" +}