From 11450f115b5f1605a159ba0bd1dbbf313d411d03 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 14 Jul 2026 00:04:07 +0200 Subject: [PATCH 1/3] feat(console): render a unified diff for multiline assert failures assert_equals/assert_same failures with multiline expected or actual values now render a git word-diff below the Expected/but got header (inline values truncated to their first line + ellipsis while the diff is shown), making command-output/file-content differences readable. Extracts the snapshot-failure diff block into a shared bashunit::console_results::render_diff helper (now color-aware: honors --no-color instead of always colorizing). Single-line failures and machine reports (JUnit/TAP/JSON) are byte-identical. Requires git (graceful plain fallback); opt out with BASHUNIT_NO_DIFF=true. Closes #777 --- .env.example | 1 + CHANGELOG.md | 1 + docs/configuration.md | 26 ++++++ src/console_results.sh | 81 ++++++++++++++++--- src/env.sh | 6 ++ tests/acceptance/bashunit_diff_test.sh | 37 +++++++++ .../fixtures/diff/multiline_diff.sh | 9 +++ tests/unit/console_results_diff_test.sh | 64 +++++++++++++++ 8 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 tests/acceptance/bashunit_diff_test.sh create mode 100644 tests/acceptance/fixtures/diff/multiline_diff.sh create mode 100644 tests/unit/console_results_diff_test.sh diff --git a/.env.example b/.env.example index 347fd35e..0c2adbf4 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,7 @@ BASHUNIT_FAIL_ON_RISKY= # Default: false (treat no-assertion tests a BASHUNIT_PROFILE= # Default: false (report slowest tests after a run) BASHUNIT_PROFILE_COUNT= # Default: 10 (how many slowest tests to report) BASHUNIT_NO_COLOR= # Default: false (disable colors) +BASHUNIT_NO_DIFF= # Default: false (disable unified diff on multiline assert failures) #─────────────────────────────────────────────────────────────────────────────── # Test Execution diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ee3983..91b9d6c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - `--jobs auto` (and `-j auto`) caps parallel concurrency at the detected CPU core count, portable across Linux/macOS/BSD (`nproc`, then `sysctl`, then `getconf`, falling back to 4). The default stays unlimited (`--jobs 0`) (#766) ### Changed +- `assert_equals`/`assert_same` failures with multiline values now render a git word-diff below the `Expected/but got` header, making differences in command output or file contents easy to spot (the inline values are truncated to their first line while the diff is shown). Requires git; single-line failures are unchanged. Opt out with `BASHUNIT_NO_DIFF=true`, and it respects `--no-color`. Machine reports (JUnit/TAP/JSON) keep the raw values (#777) - Faster test execution: `assert_match_snapshot` no longer forks on the success path. Carriage-return stripping, the test-function-name and snapshot-path resolution, and the snapshot-file read are now pure-bash (return slots + `$( ~3.0s on bash 3.2. No behaviour change (#775) - Faster test execution with `--tag`/`--exclude-tag`: each test file's `# @tag` annotations are scanned once in a single `awk` pass and cached, replacing a per-function backward walk that forked `grep`+`sed` on every comment line (and ran twice per function). 100 tagged tests with `--tag` ~2.92s -> ~0.68s on bash 3.2. No behaviour change (#773) - Faster test execution: removed the remaining `run_test` hot-path forks. Test-label normalization and the per-test clock capture use fork-free return slots (`bashunit::clock::now_to_slot` reads `EPOCHREALTIME` directly on Bash 5.0+), parallel mode is resolved once instead of re-checking env + OS on every `is_enabled` call, and the `no-parallel-tests` opt-out is detected in the existing single-pass file scan rather than a separate per-file `grep`. No behaviour change (#774) diff --git a/docs/configuration.md b/docs/configuration.md index 3e233f4b..d321c81b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -562,6 +562,32 @@ BASHUNIT_SHOW_OUTPUT_ON_FAILURE=false ``` ::: +## Diff on multiline failures + +> `BASHUNIT_NO_DIFF=true|false` + +When an `assert_equals`/`assert_same` failure involves a multiline value, +bashunit renders a git word-diff below the `Expected/but got` header so the +difference is easy to spot; the inline values are truncated to their first line +while the diff is shown. Requires git (falls back to the plain output when it is +unavailable), respects `--no-color`, and never affects single-line failures or +machine reports (JUnit/TAP/JSON). `false` by default — set `BASHUNIT_NO_DIFF=true` +to always print the full raw values instead. + +::: code-group +```[Output example] +✗ Failed: My test function + Expected 'alpha…' + but got 'alpha…' + alpha + [-beta-]{+DELTA+} + gamma +``` +```bash [.env to disable] +BASHUNIT_NO_DIFF=true +``` +::: + ## Color output > `NO_COLOR=1` diff --git a/src/console_results.sh b/src/console_results.sh index 67a74c23..3d9ecd62 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -302,6 +302,47 @@ ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ bashunit::state::print_line "failure" "$line" } +## +# Renders a git word-diff of two files, indented, and echoes it. Colorized +# unless --no-color is active. Empty when git is unavailable or files match. +# Shared by the snapshot-failure and multiline assert-failure renderers. +# Arguments: $1 expected file path, $2 actual file path +## +function bashunit::console_results::render_diff() { + local expected_file=$1 + local actual_file=$2 + + if ! bashunit::dependencies::has_git; then + return 0 + fi + + local color_flag="--color=always" + if bashunit::env::is_no_color_enabled; then + color_flag="--color=never" + fi + + # `git diff` exits non-zero when the files differ; the `|| true` keeps that + # from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's + # header lines; `sed` indents the body. + git diff --no-index --word-diff "$color_flag" \ + "$expected_file" "$actual_file" 2>/dev/null | + tail -n +6 | sed "s/^/ /" || true +} + +## +# Echoes a value's first line, appending an ellipsis when it spans several +# lines. Used to keep the inline quoted value on one line when a diff follows. +## +function bashunit::console_results::first_line_ellipsis() { + local text=$1 + local first="${text%%$'\n'*}" + if [ "$first" != "$text" ]; then + printf '%s…' "$first" + else + printf '%s' "$text" + fi +} + function bashunit::console_results::print_failed_test() { local function_name=$1 local expected=$2 @@ -310,12 +351,41 @@ function bashunit::console_results::print_failed_test() { local extra_key=${5-} local extra_value=${6-} + # For multiline values, render a unified diff below the header (git required, + # opt out with BASHUNIT_NO_DIFF). Single-line output stays byte-identical. + local show_diff=false + case "$expected$actual" in + *$'\n'*) + if bashunit::env::is_diff_enabled && bashunit::dependencies::has_git; then + show_diff=true + fi + ;; + esac + + local display_expected=$expected + local display_actual=$actual + if [ "$show_diff" = true ]; then + display_expected="$(bashunit::console_results::first_line_ellipsis "$expected")" + display_actual="$(bashunit::console_results::first_line_ellipsis "$actual")" + fi + local line line="$(printf "\ ${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s ${_BASHUNIT_COLOR_FAINT}Expected${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ - "${function_name}" "${expected}" "${failure_condition_message}" "${actual}")" + "${function_name}" "${display_expected}" "${failure_condition_message}" "${display_actual}")" + + if [ "$show_diff" = true ]; then + local _expected_file _actual_file + _expected_file="$(bashunit::temp_file diff_expected)" + _actual_file="$(bashunit::temp_file diff_actual)" + printf '%s\n' "$expected" >"$_expected_file" + printf '%s\n' "$actual" >"$_actual_file" + line="$line +$(bashunit::console_results::render_diff "$_expected_file" "$_actual_file")" + rm -f "$_expected_file" "$_actual_file" + fi if [ -n "$extra_key" ]; then line="$line$(printf "\ @@ -342,14 +412,7 @@ function bashunit::console_results::print_failed_snapshot_test() { local actual_file="${snapshot_file}.tmp" echo "$actual_content" >"$actual_file" - # `git diff` exits non-zero when the files differ; guard with `|| true` so - # the assignment does not trip `set -e`/`pipefail` under --strict. - local git_diff_output - git_diff_output="$(git diff --no-index --word-diff --color=always \ - "$snapshot_file" "$actual_file" 2>/dev/null | - tail -n +6 | sed "s/^/ /")" || true - - line="$line$git_diff_output" + line="$line$(bashunit::console_results::render_diff "$snapshot_file" "$actual_file")" rm "$actual_file" else line="$line$(bashunit::console_results::snapshot_line_diff \ diff --git a/src/env.sh b/src/env.sh index ab72166e..39151d9e 100644 --- a/src/env.sh +++ b/src/env.sh @@ -114,6 +114,7 @@ _BASHUNIT_DEFAULT_SKIP_ENV_FILE="false" _BASHUNIT_DEFAULT_LOGIN_SHELL="false" _BASHUNIT_DEFAULT_FAILURES_ONLY="false" _BASHUNIT_DEFAULT_NO_COLOR="false" +_BASHUNIT_DEFAULT_NO_DIFF="false" _BASHUNIT_DEFAULT_SHOW_OUTPUT_ON_FAILURE="true" _BASHUNIT_DEFAULT_NO_PROGRESS="false" _BASHUNIT_DEFAULT_OUTPUT_FORMAT="" @@ -151,6 +152,7 @@ _BASHUNIT_DEFAULT_SHARD_TOTAL="" : "${BASHUNIT_LOGIN_SHELL:=${LOGIN_SHELL:=$_BASHUNIT_DEFAULT_LOGIN_SHELL}}" : "${BASHUNIT_FAILURES_ONLY:=${FAILURES_ONLY:=$_BASHUNIT_DEFAULT_FAILURES_ONLY}}" : "${BASHUNIT_SHOW_OUTPUT_ON_FAILURE:=${SHOW_OUTPUT_ON_FAILURE:=$_BASHUNIT_DEFAULT_SHOW_OUTPUT_ON_FAILURE}}" +: "${BASHUNIT_NO_DIFF:=${NO_DIFF:=$_BASHUNIT_DEFAULT_NO_DIFF}}" : "${BASHUNIT_NO_PROGRESS:=${NO_PROGRESS:=$_BASHUNIT_DEFAULT_NO_PROGRESS}}" : "${BASHUNIT_OUTPUT_FORMAT:=${OUTPUT_FORMAT:=$_BASHUNIT_DEFAULT_OUTPUT_FORMAT}}" : "${BASHUNIT_FAIL_ON_RISKY:=${FAIL_ON_RISKY:=$_BASHUNIT_DEFAULT_FAIL_ON_RISKY}}" @@ -329,6 +331,10 @@ function bashunit::env::is_no_color_enabled() { [ "$BASHUNIT_NO_COLOR" = "true" ] } +function bashunit::env::is_diff_enabled() { + [ "$BASHUNIT_NO_DIFF" != "true" ] +} + ## # Whether the current terminal can render ANSI color sequences. # Returns 1 when TERM=dumb or when `tput colors` reports fewer than 8. diff --git a/tests/acceptance/bashunit_diff_test.sh b/tests/acceptance/bashunit_diff_test.sh new file mode 100644 index 00000000..334b20f7 --- /dev/null +++ b/tests/acceptance/bashunit_diff_test.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +function set_up_before_script() { + FIXTURE="./tests/acceptance/fixtures/diff/multiline_diff.sh" +} + +function test_multiline_failure_renders_a_word_diff() { + if ! bashunit::dependencies::has_git; then + bashunit::skip "git not available" && return + fi + + local output + output=$(./bashunit --no-parallel --no-color --filter multiline "$FIXTURE" 2>&1) || true + + assert_contains "[-beta-]{+DELTA+}" "$output" + assert_contains "alpha…" "$output" +} + +function test_no_diff_env_keeps_full_multiline_values() { + local output + output=$(BASHUNIT_NO_DIFF=true ./bashunit --no-parallel --no-color --filter multiline "$FIXTURE" 2>&1) || true + + assert_not_contains "[-beta-]" "$output" + assert_not_contains "alpha…" "$output" + assert_contains "beta" "$output" + assert_contains "gamma" "$output" +} + +function test_single_line_failure_stays_inline_without_diff() { + local output + output=$(./bashunit --no-parallel --no-color --filter single_line "$FIXTURE" 2>&1) || true + + assert_contains "'expected_value'" "$output" + assert_contains "'actual_value'" "$output" + assert_not_contains "[-" "$output" +} diff --git a/tests/acceptance/fixtures/diff/multiline_diff.sh b/tests/acceptance/fixtures/diff/multiline_diff.sh new file mode 100644 index 00000000..89a7910a --- /dev/null +++ b/tests/acceptance/fixtures/diff/multiline_diff.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +function test_diff_multiline_mismatch() { + assert_same "$(printf 'alpha\nbeta\ngamma')" "$(printf 'alpha\nDELTA\ngamma')" +} + +function test_diff_single_line_mismatch() { + assert_same "expected_value" "actual_value" +} diff --git a/tests/unit/console_results_diff_test.sh b/tests/unit/console_results_diff_test.sh new file mode 100644 index 00000000..b8500cd4 --- /dev/null +++ b/tests/unit/console_results_diff_test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +function tear_down() { + unset BASHUNIT_NO_DIFF +} + +function test_first_line_ellipsis_leaves_single_line_untouched() { + assert_same "hello" "$(bashunit::console_results::first_line_ellipsis "hello")" +} + +function test_first_line_ellipsis_truncates_multiline_to_first_line() { + local value + value=$(printf 'first\nsecond\nthird') + assert_same "first…" "$(bashunit::console_results::first_line_ellipsis "$value")" +} + +function test_is_diff_enabled_by_default() { + unset BASHUNIT_NO_DIFF + local rc=0 + bashunit::env::is_diff_enabled || rc=$? + assert_same 0 "$rc" +} + +function test_is_diff_disabled_with_no_diff_env() { + export BASHUNIT_NO_DIFF=true + local rc=0 + bashunit::env::is_diff_enabled || rc=$? + assert_same 1 "$rc" +} + +function test_render_diff_is_empty_for_identical_files() { + if ! bashunit::dependencies::has_git; then + bashunit::skip "git not available" && return + fi + local a b + a=$(bashunit::temp_file diff_a) + b=$(bashunit::temp_file diff_b) + printf 'same\ncontent\n' >"$a" + printf 'same\ncontent\n' >"$b" + + assert_empty "$(bashunit::console_results::render_diff "$a" "$b")" + rm -f "$a" "$b" +} + +function test_render_diff_shows_changed_tokens_without_color() { + if ! bashunit::dependencies::has_git; then + bashunit::skip "git not available" && return + fi + export BASHUNIT_NO_COLOR=true + local a b + a=$(bashunit::temp_file diff_a) + b=$(bashunit::temp_file diff_b) + printf 'alpha\nbeta\ngamma\n' >"$a" + printf 'alpha\nDELTA\ngamma\n' >"$b" + + local output + output=$(bashunit::console_results::render_diff "$a" "$b") + + assert_contains "beta" "$output" + assert_contains "DELTA" "$output" + rm -f "$a" "$b" + unset BASHUNIT_NO_COLOR +} From dcfc5cccc90b81849c8a44e1a2d58525ecac46ea Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 14 Jul 2026 00:14:35 +0200 Subject: [PATCH 2/3] test(console): skip env file in diff acceptance tests CI copies .env.example to .env, whose empty BASHUNIT_NO_DIFF= line was sourced with allexport and clobbered the inherited BASHUNIT_NO_DIFF=true, re-enabling the diff. Pass --skip-env-file (as the other spawned-bashunit acceptance tests do) so the test env is deterministic. --- tests/acceptance/bashunit_diff_test.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/bashunit_diff_test.sh b/tests/acceptance/bashunit_diff_test.sh index 334b20f7..5288beb6 100644 --- a/tests/acceptance/bashunit_diff_test.sh +++ b/tests/acceptance/bashunit_diff_test.sh @@ -11,7 +11,7 @@ function test_multiline_failure_renders_a_word_diff() { fi local output - output=$(./bashunit --no-parallel --no-color --filter multiline "$FIXTURE" 2>&1) || true + output=$(./bashunit --no-parallel --no-color --skip-env-file --filter multiline "$FIXTURE" 2>&1) || true assert_contains "[-beta-]{+DELTA+}" "$output" assert_contains "alpha…" "$output" @@ -19,7 +19,7 @@ function test_multiline_failure_renders_a_word_diff() { function test_no_diff_env_keeps_full_multiline_values() { local output - output=$(BASHUNIT_NO_DIFF=true ./bashunit --no-parallel --no-color --filter multiline "$FIXTURE" 2>&1) || true + output=$(BASHUNIT_NO_DIFF=true ./bashunit --no-parallel --no-color --skip-env-file --filter multiline "$FIXTURE" 2>&1) || true assert_not_contains "[-beta-]" "$output" assert_not_contains "alpha…" "$output" @@ -29,7 +29,7 @@ function test_no_diff_env_keeps_full_multiline_values() { function test_single_line_failure_stays_inline_without_diff() { local output - output=$(./bashunit --no-parallel --no-color --filter single_line "$FIXTURE" 2>&1) || true + output=$(./bashunit --no-parallel --no-color --skip-env-file --filter single_line "$FIXTURE" 2>&1) || true assert_contains "'expected_value'" "$output" assert_contains "'actual_value'" "$output" From 2492c866ecff897bc5ff222db9f7f5cbf83961b6 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 14 Jul 2026 06:56:33 +0200 Subject: [PATCH 3/3] style(test): wrap long line in diff acceptance test --- tests/acceptance/bashunit_diff_test.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/acceptance/bashunit_diff_test.sh b/tests/acceptance/bashunit_diff_test.sh index 5288beb6..a7dc1bec 100644 --- a/tests/acceptance/bashunit_diff_test.sh +++ b/tests/acceptance/bashunit_diff_test.sh @@ -19,7 +19,8 @@ function test_multiline_failure_renders_a_word_diff() { function test_no_diff_env_keeps_full_multiline_values() { local output - output=$(BASHUNIT_NO_DIFF=true ./bashunit --no-parallel --no-color --skip-env-file --filter multiline "$FIXTURE" 2>&1) || true + output=$(BASHUNIT_NO_DIFF=true ./bashunit --no-parallel --no-color --skip-env-file \ + --filter multiline "$FIXTURE" 2>&1) || true assert_not_contains "[-beta-]" "$output" assert_not_contains "alpha…" "$output"