From b477b730501f01001d30d360c55104b8b80f8ade Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 29 Apr 2026 13:21:24 +0200 Subject: [PATCH 1/2] feat(cli): add --generate-baseline and --use-baseline Capture the current run's failed/risky/incomplete tests into a baseline XML file so they no longer fail the suite. Newly introduced issues still fail the run, while baselined ones are reported separately and excluded from the failure count. Closes #284 --- CHANGELOG.md | 1 + bashunit | 1 + docs/command-line.md | 17 ++ docs/configuration.md | 16 ++ src/baseline.sh | 171 ++++++++++++++++ src/console_header.sh | 2 + src/console_results.sh | 5 + src/env.sh | 6 + src/main.sh | 22 +++ src/reports.sh | 3 +- src/runner.sh | 16 ++ src/state.sh | 9 + tests/acceptance/bashunit_baseline_test.sh | 83 ++++++++ .../fixtures/test_bashunit_when_baseline.sh | 13 ++ tests/unit/baseline_test.sh | 182 ++++++++++++++++++ 15 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 src/baseline.sh create mode 100644 tests/acceptance/bashunit_baseline_test.sh create mode 100644 tests/acceptance/fixtures/test_bashunit_when_baseline.sh create mode 100644 tests/unit/baseline_test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2456d7f9..bc45f4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Display captured test output on assertion failures when `--show-output` is enabled (#637) +- `--generate-baseline ` flag (and `BASHUNIT_BASELINE_GENERATE` env var) writes an XML baseline of the run's failed/risky/incomplete tests, and `--use-baseline ` (`BASHUNIT_BASELINE_USE`) ignores those known issues so only newly introduced ones fail the run (#284) ### Changed - Speed up coverage report generation by collapsing the per-line non-executable pattern checks in `bashunit::coverage::is_executable_line` into a single combined `grep` invocation (#636) diff --git a/bashunit b/bashunit index ef1be007..d158de98 100755 --- a/bashunit +++ b/bashunit @@ -76,6 +76,7 @@ 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/baseline.sh" source "$BASHUNIT_ROOT_DIR/src/runner.sh" source "$BASHUNIT_ROOT_DIR/src/bashunit.sh" source "$BASHUNIT_ROOT_DIR/src/init.sh" diff --git a/docs/command-line.md b/docs/command-line.md index 055a2338..39afdf72 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -67,6 +67,8 @@ bashunit test tests/ --parallel --simple | `-p, --parallel` | Run tests in parallel | | `--no-parallel` | Run tests sequentially | | `-r, --report-html ` | Write HTML report | +| `--generate-baseline ` | Write a baseline XML of failed/risky/incomplete tests | +| `--use-baseline ` | Ignore tests listed in the baseline | | `-R, --run-all` | Run all assertions (don't stop on first failure) | | `-s, --simple` | Simple output (dots) | | `--detailed` | Detailed output (default) | @@ -366,6 +368,21 @@ bashunit test tests/ --log-gha gha.log && cat gha.log The `--log-gha` flag writes GitHub Actions workflow commands (`::error`, `::warning`, `::notice`) for failed, risky and incomplete tests. When streamed to stdout on a runner, they appear as inline annotations in the "Files changed" tab of a pull request. +### Baseline + +Capture the run's existing failed, risky and incomplete tests so they no longer fail the suite, allowing you to focus on newly introduced issues. + +::: code-group +```bash [Generate] +bashunit test tests/ --generate-baseline baseline.xml +``` +```bash [Use] +bashunit test tests/ --use-baseline baseline.xml +``` +::: + +When `--generate-baseline` is set, the run exits with code 0 even if tests failed: the failures are recorded in the XML for later use. When `--use-baseline` is set, any failed/risky/incomplete test that matches an entry in the baseline (by file, name and status) is reported as `baselined` and excluded from the failure count. Newly failing tests still fail the run. + ### Show Output on Failure > `bashunit test --show-output` diff --git a/docs/configuration.md b/docs/configuration.md index fbed64ce..d83904db 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -260,6 +260,22 @@ BASHUNIT_REPORT_HTML=report.html ``` ::: +## Baseline + +> `BASHUNIT_BASELINE_GENERATE=file` +> `BASHUNIT_BASELINE_USE=file` + +`BASHUNIT_BASELINE_GENERATE` writes an XML baseline of every failed, risky or incomplete test in the current run. The run then exits with code 0 so the baseline can be committed. + +`BASHUNIT_BASELINE_USE` loads a previously generated baseline. Tests whose file, name and status all match a baseline entry are reported as `baselined` and excluded from the failure count. Tests not present in the baseline still fail the run. + +::: code-group +```bash [Example] +BASHUNIT_BASELINE_GENERATE=baseline.xml +BASHUNIT_BASELINE_USE=baseline.xml +``` +::: + ## Bootstrap > `BASHUNIT_BOOTSTRAP=file` diff --git a/src/baseline.sh b/src/baseline.sh new file mode 100644 index 00000000..46d6ef4a --- /dev/null +++ b/src/baseline.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash + +_BASHUNIT_BASELINE_FILES=() +_BASHUNIT_BASELINE_NAMES=() +_BASHUNIT_BASELINE_STATUSES=() + +## +# Escape XML special characters for use inside attribute values. +# Arguments: $1 - text +## +function bashunit::baseline::__xml_escape_attr() { + local text="$1" + text="${text//&/&}" + text="${text///>}" + text="${text//\"/"}" + text="${text//\'/'}" + printf '%s' "$text" +} + +## +# Decode the XML entities used by bashunit::baseline::__xml_escape_attr. +# Arguments: $1 - text +## +function bashunit::baseline::__xml_unescape_attr() { + local text="$1" + text="${text//</<}" + text="${text//>/>}" + text="${text//"/\"}" + text="${text//'/\'}" + text="${text//&/&}" + printf '%s' "$text" +} + +## +# Generate a baseline XML file listing the current run's failed/risky/incomplete tests. +# Arguments: $1 - output file path +## +function bashunit::baseline::generate() { + local output_file="$1" + + { + echo '' + echo '' + + local i status file name + for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do + status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" + + case "$status" in + failed | risky | incomplete) ;; + *) continue ;; + esac + + file="$(bashunit::baseline::__xml_escape_attr "${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}")" + name="$(bashunit::baseline::__xml_escape_attr "${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}")" + echo " " + done + + echo '' + } >"$output_file" +} + +## +# Load entries from a baseline XML file into the lookup arrays. +# Arguments: $1 - input file path +# Returns: 0 success, 1 if file missing +## +function bashunit::baseline::load() { + local input_file="$1" + + if [ ! -f "$input_file" ]; then + return 1 + fi + + _BASHUNIT_BASELINE_FILES=() + _BASHUNIT_BASELINE_NAMES=() + _BASHUNIT_BASELINE_STATUSES=() + + local line file name status + while IFS= read -r line; do + case "$line" in + *" Write HTML report + --generate-baseline Write a baseline XML of current failed/risky/incomplete tests + --use-baseline Ignore tests listed in the baseline (don't count as failures) -s, --simple Simple output (dots) --detailed Detailed output (default) --output Output format: tap (TAP version 13) diff --git a/src/console_results.sh b/src/console_results.sh index 20d1291c..a962bcaf 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -31,6 +31,7 @@ function bashunit::console_results::render_result() { local tests_snapshot=$_BASHUNIT_TESTS_SNAPSHOT local tests_failed=$_BASHUNIT_TESTS_FAILED local tests_risky=$_BASHUNIT_TESTS_RISKY + local tests_baselined=$_BASHUNIT_TESTS_BASELINED local assertions_passed=$_BASHUNIT_ASSERTIONS_PASSED local assertions_skipped=$_BASHUNIT_ASSERTIONS_SKIPPED local assertions_incomplete=$_BASHUNIT_ASSERTIONS_INCOMPLETE @@ -44,6 +45,7 @@ function bashunit::console_results::render_result() { total_tests=$((total_tests + tests_snapshot)) total_tests=$((total_tests + tests_failed)) total_tests=$((total_tests + tests_risky)) + total_tests=$((total_tests + tests_baselined)) local total_assertions=0 total_assertions=$((total_assertions + assertions_passed)) @@ -71,6 +73,9 @@ function bashunit::console_results::render_result() { if [ "$tests_risky" -gt 0 ]; then printf " %s%s risky%s," "$_BASHUNIT_COLOR_RISKY" "$tests_risky" "$_BASHUNIT_COLOR_DEFAULT" fi + if [ "$tests_baselined" -gt 0 ]; then + printf " %s%s baselined%s," "$_BASHUNIT_COLOR_SKIPPED" "$tests_baselined" "$_BASHUNIT_COLOR_DEFAULT" + fi printf " %s total\n" "$total_tests" printf "%sAssertions:%s" "$_BASHUNIT_COLOR_FAINT" "$_BASHUNIT_COLOR_DEFAULT" diff --git a/src/env.sh b/src/env.sh index 2a809131..89cae418 100644 --- a/src/env.sh +++ b/src/env.sh @@ -16,6 +16,8 @@ _BASHUNIT_DEFAULT_DEV_LOG="" _BASHUNIT_DEFAULT_LOG_JUNIT="" _BASHUNIT_DEFAULT_LOG_GHA="" _BASHUNIT_DEFAULT_REPORT_HTML="" +_BASHUNIT_DEFAULT_BASELINE_GENERATE="" +_BASHUNIT_DEFAULT_BASELINE_USE="" # Coverage defaults (following kcov, bashcov, SimpleCov conventions) _BASHUNIT_DEFAULT_COVERAGE="false" @@ -34,6 +36,8 @@ _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH="80" : "${BASHUNIT_LOG_JUNIT:=${LOG_JUNIT:=$_BASHUNIT_DEFAULT_LOG_JUNIT}}" : "${BASHUNIT_LOG_GHA:=${LOG_GHA:=$_BASHUNIT_DEFAULT_LOG_GHA}}" : "${BASHUNIT_REPORT_HTML:=${REPORT_HTML:=$_BASHUNIT_DEFAULT_REPORT_HTML}}" +: "${BASHUNIT_BASELINE_GENERATE:=${BASELINE_GENERATE:=$_BASHUNIT_DEFAULT_BASELINE_GENERATE}}" +: "${BASHUNIT_BASELINE_USE:=${BASELINE_USE:=$_BASHUNIT_DEFAULT_BASELINE_USE}}" # Coverage : "${BASHUNIT_COVERAGE:=${COVERAGE:=$_BASHUNIT_DEFAULT_COVERAGE}}" @@ -240,6 +244,8 @@ function bashunit::env::print_verbose() { "BASHUNIT_LOG_JUNIT" "BASHUNIT_LOG_GHA" "BASHUNIT_REPORT_HTML" + "BASHUNIT_BASELINE_GENERATE" + "BASHUNIT_BASELINE_USE" "BASHUNIT_PARALLEL_RUN" "BASHUNIT_SHOW_HEADER" "BASHUNIT_HEADER_ASCII_ART" diff --git a/src/main.sh b/src/main.sh index d33d8335..43ffd029 100644 --- a/src/main.sh +++ b/src/main.sh @@ -104,6 +104,14 @@ function bashunit::main::cmd_test() { export BASHUNIT_REPORT_HTML="$2" shift ;; + --generate-baseline) + export BASHUNIT_BASELINE_GENERATE="$2" + shift + ;; + --use-baseline) + export BASHUNIT_BASELINE_USE="$2" + shift + ;; --no-output) export BASHUNIT_NO_OUTPUT=true ;; @@ -673,6 +681,14 @@ function bashunit::main::exec_tests() { printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '#' fi + if [ -n "${BASHUNIT_BASELINE_USE:-}" ]; then + if ! bashunit::baseline::load "$BASHUNIT_BASELINE_USE"; then + printf "%sError: baseline file not found: %s%s\n" \ + "${_BASHUNIT_COLOR_FAILED}" "$BASHUNIT_BASELINE_USE" "${_BASHUNIT_COLOR_DEFAULT}" + exit 1 + fi + fi + bashunit::runner::load_test_files "$filter" "$tag_filter" "$exclude_tag_filter" "${test_files[@]}" if bashunit::parallel::is_enabled; then @@ -704,6 +720,12 @@ function bashunit::main::exec_tests() { bashunit::reports::generate_report_html "$BASHUNIT_REPORT_HTML" fi + if [ -n "${BASHUNIT_BASELINE_GENERATE:-}" ]; then + bashunit::baseline::generate "$BASHUNIT_BASELINE_GENERATE" + # Listed issues are intentional baseline; treat run as success. + exit_code=0 + fi + # Generate coverage report if enabled if bashunit::env::is_coverage_enabled; then # Aggregate per-process coverage data from parallel runs diff --git a/src/reports.sh b/src/reports.sh index 85e1dbf6..9e993596 100755 --- a/src/reports.sh +++ b/src/reports.sh @@ -37,7 +37,8 @@ function bashunit::reports::add_test() { { [ -n "${BASHUNIT_LOG_JUNIT:-}" ] || [ -n "${BASHUNIT_REPORT_HTML:-}" ] || - [ -n "${BASHUNIT_LOG_GHA:-}" ] + [ -n "${BASHUNIT_LOG_GHA:-}" ] || + [ -n "${BASHUNIT_BASELINE_GENERATE:-}" ] } || return 0 local file="$1" diff --git a/src/runner.sh b/src/runner.sh index b57a390a..eebdf5c5 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -789,6 +789,10 @@ function bashunit::runner::run_test() { fi if [ -n "$runtime_error" ] || [ "$test_exit_code" -ne 0 ]; then + if bashunit::baseline::match_and_record \ + "$test_file" "$failure_label" "failed" "$duration" "$total_assertions"; then + return + fi bashunit::state::add_tests_failed local error_message="$runtime_error" if [ -n "$hook_failure" ] && [ -n "$hook_message" ]; then @@ -812,6 +816,10 @@ function bashunit::runner::run_test() { fi if [ "$current_assertions_failed" != "$_BASHUNIT_ASSERTIONS_FAILED" ]; then + if bashunit::baseline::match_and_record \ + "$test_file" "$label" "failed" "$duration" "$total_assertions"; then + return + fi bashunit::state::add_tests_failed bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$subshell_output" local assertion_runtime_output @@ -845,6 +853,10 @@ function bashunit::runner::run_test() { fi if [ "$current_assertions_incomplete" != "$_BASHUNIT_ASSERTIONS_INCOMPLETE" ]; then + if bashunit::baseline::match_and_record \ + "$test_file" "$label" "incomplete" "$duration" "$total_assertions"; then + return + fi bashunit::state::add_tests_incomplete bashunit::reports::add_test_incomplete "$test_file" "$label" "$duration" "$total_assertions" bashunit::runner::write_incomplete_result_output "$test_file" "$fn_name" "$subshell_output" @@ -862,6 +874,10 @@ function bashunit::runner::run_test() { # Check for risky test (zero assertions) if [ "$total_assertions" -eq 0 ]; then + if bashunit::baseline::match_and_record \ + "$test_file" "$label" "risky" "$duration" "$total_assertions"; then + return + fi if bashunit::env::is_fail_on_risky_enabled; then local risky_msg="Test has no assertions (risky)" bashunit::state::add_tests_failed diff --git a/src/state.sh b/src/state.sh index 80c8281c..0f250705 100644 --- a/src/state.sh +++ b/src/state.sh @@ -13,6 +13,7 @@ _BASHUNIT_TESTS_SKIPPED=0 _BASHUNIT_TESTS_INCOMPLETE=0 _BASHUNIT_TESTS_SNAPSHOT=0 _BASHUNIT_TESTS_RISKY=0 +_BASHUNIT_TESTS_BASELINED=0 _BASHUNIT_ASSERTIONS_PASSED=0 _BASHUNIT_ASSERTIONS_FAILED=0 _BASHUNIT_ASSERTIONS_SKIPPED=0 @@ -77,6 +78,14 @@ function bashunit::state::add_tests_risky() { ((_BASHUNIT_TESTS_RISKY++)) || true } +function bashunit::state::get_tests_baselined() { + echo "$_BASHUNIT_TESTS_BASELINED" +} + +function bashunit::state::add_tests_baselined() { + ((_BASHUNIT_TESTS_BASELINED++)) || true +} + function bashunit::state::get_assertions_passed() { echo "$_BASHUNIT_ASSERTIONS_PASSED" } diff --git a/tests/acceptance/bashunit_baseline_test.sh b/tests/acceptance/bashunit_baseline_test.sh new file mode 100644 index 00000000..d52f02af --- /dev/null +++ b/tests/acceptance/bashunit_baseline_test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +function set_up_before_script() { + TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" +} + +function set_up() { + BASELINE_FILE=$(mktemp "${TMPDIR:-/tmp}/bashunit-baseline-XXXXXX") +} + +function tear_down() { + [ -n "${BASELINE_FILE:-}" ] && [ -f "$BASELINE_FILE" ] && rm -f "$BASELINE_FILE" +} + +function test_bashunit_generate_baseline_writes_xml_with_failures() { + local test_file=./tests/acceptance/fixtures/test_bashunit_when_baseline.sh + + ./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + --generate-baseline "$BASELINE_FILE" "$test_file" >/dev/null 2>&1 || true + + assert_file_exists "$BASELINE_FILE" + + local content + content=$(cat "$BASELINE_FILE") + + assert_contains '' "$content" + assert_contains '' "$content" + assert_contains 'name="Fails"' "$content" + assert_contains 'name="Also fails"' "$content" + assert_contains 'status="failed"' "$content" + assert_not_contains 'name="Passes"' "$content" +} + +function test_bashunit_generate_baseline_run_succeeds_with_zero_exit() { + local test_file=./tests/acceptance/fixtures/test_bashunit_when_baseline.sh + + local exit_code=0 + ./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + --generate-baseline "$BASELINE_FILE" "$test_file" >/dev/null 2>&1 || exit_code=$? + + assert_equals "0" "$exit_code" +} + +function test_bashunit_use_baseline_suppresses_listed_failures() { + local test_file=./tests/acceptance/fixtures/test_bashunit_when_baseline.sh + + ./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + --generate-baseline "$BASELINE_FILE" "$test_file" >/dev/null 2>&1 || true + + local exit_code=0 + ./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + --use-baseline "$BASELINE_FILE" "$test_file" >/dev/null 2>&1 || exit_code=$? + + assert_equals "0" "$exit_code" +} + +function test_bashunit_use_baseline_still_reports_unmatched_failures() { + local test_file=./tests/acceptance/fixtures/test_bashunit_when_baseline.sh + + cat >"$BASELINE_FILE" < + + + +XML + + local exit_code=0 + ./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + --use-baseline "$BASELINE_FILE" "$test_file" >/dev/null 2>&1 || exit_code=$? + + assert_not_equals "0" "$exit_code" +} + +function test_bashunit_use_baseline_errors_when_file_missing() { + local test_file=./tests/acceptance/fixtures/test_bashunit_when_baseline.sh + + local output exit_code=0 + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + --use-baseline /nonexistent/baseline.xml "$test_file" 2>&1) || exit_code=$? + + assert_not_equals "0" "$exit_code" + assert_contains "baseline file not found" "$output" +} diff --git a/tests/acceptance/fixtures/test_bashunit_when_baseline.sh b/tests/acceptance/fixtures/test_bashunit_when_baseline.sh new file mode 100644 index 00000000..c7bec06f --- /dev/null +++ b/tests/acceptance/fixtures/test_bashunit_when_baseline.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +function test_passes() { + assert_same 1 1 +} + +function test_fails() { + assert_same 1 2 +} + +function test_also_fails() { + assert_same "expected" "actual" +} diff --git a/tests/unit/baseline_test.sh b/tests/unit/baseline_test.sh new file mode 100644 index 00000000..3d7121b9 --- /dev/null +++ b/tests/unit/baseline_test.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2034,SC2329 + +function set_up_before_script() { + _TEMP_OUTPUT_FILE="" +} + +function set_up() { + _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_BASELINE_FILES=() + _BASHUNIT_BASELINE_NAMES=() + _BASHUNIT_BASELINE_STATUSES=() + + _TEMP_OUTPUT_FILE=$(mktemp) +} + +function tear_down() { + [ -n "$_TEMP_OUTPUT_FILE" ] && [ -f "$_TEMP_OUTPUT_FILE" ] && rm -f "$_TEMP_OUTPUT_FILE" +} + +# === generate === + +function test_generate_writes_xml_header_and_root() { + bashunit::baseline::generate "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_contains '' "$content" + assert_contains '' "$content" + assert_contains '' "$content" +} + +function test_generate_includes_failed_test_entry() { + _BASHUNIT_REPORTS_TEST_FILES=("tests/foo_test.sh") + _BASHUNIT_REPORTS_TEST_NAMES=("test_should_x") + _BASHUNIT_REPORTS_TEST_STATUSES=("failed") + + bashunit::baseline::generate "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_contains '' "$content" +} + +function test_generate_includes_risky_and_incomplete_entries() { + _BASHUNIT_REPORTS_TEST_FILES=("a.sh" "b.sh") + _BASHUNIT_REPORTS_TEST_NAMES=("test_risky" "test_incomplete") + _BASHUNIT_REPORTS_TEST_STATUSES=("risky" "incomplete") + + bashunit::baseline::generate "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_contains 'name="test_risky" status="risky"' "$content" + assert_contains 'name="test_incomplete" status="incomplete"' "$content" +} + +function test_generate_excludes_passed_and_skipped_tests() { + _BASHUNIT_REPORTS_TEST_FILES=("a.sh" "b.sh") + _BASHUNIT_REPORTS_TEST_NAMES=("test_passed" "test_skipped") + _BASHUNIT_REPORTS_TEST_STATUSES=("passed" "skipped") + + bashunit::baseline::generate "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_not_contains 'test_passed' "$content" + assert_not_contains 'test_skipped' "$content" +} + +function test_generate_xml_escapes_special_chars_in_attributes() { + _BASHUNIT_REPORTS_TEST_FILES=('tests/foo & "bar".sh') + _BASHUNIT_REPORTS_TEST_NAMES=('test__&_y') + _BASHUNIT_REPORTS_TEST_STATUSES=("failed") + + bashunit::baseline::generate "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_contains '&' "$content" + assert_contains '<' "$content" + assert_contains '>' "$content" + assert_contains '"' "$content" +} + +# === load === + +function test_load_populates_baseline_arrays() { + cat >"$_TEMP_OUTPUT_FILE" <<'XML' + + + + + +XML + + bashunit::baseline::load "$_TEMP_OUTPUT_FILE" + + assert_same "2" "${#_BASHUNIT_BASELINE_FILES[@]}" + assert_same "tests/foo_test.sh" "${_BASHUNIT_BASELINE_FILES[0]}" + assert_same "test_should_x" "${_BASHUNIT_BASELINE_NAMES[0]}" + assert_same "failed" "${_BASHUNIT_BASELINE_STATUSES[0]}" + assert_same "tests/bar_test.sh" "${_BASHUNIT_BASELINE_FILES[1]}" + assert_same "test_risky_one" "${_BASHUNIT_BASELINE_NAMES[1]}" + assert_same "risky" "${_BASHUNIT_BASELINE_STATUSES[1]}" +} + +function test_load_decodes_xml_entities() { + cat >"$_TEMP_OUTPUT_FILE" <<'XML' + + + + +XML + + bashunit::baseline::load "$_TEMP_OUTPUT_FILE" + + assert_same 'a & b.sh' "${_BASHUNIT_BASELINE_FILES[0]}" + assert_same 'test__&_"y"' "${_BASHUNIT_BASELINE_NAMES[0]}" +} + +function test_load_returns_error_for_missing_file() { + local rc=0 + bashunit::baseline::load "/nonexistent/baseline.xml" 2>/dev/null || rc=$? + + assert_not_equals "0" "$rc" +} + +# === contains === + +function test_contains_returns_true_for_matching_entry() { + _BASHUNIT_BASELINE_FILES=("tests/foo_test.sh") + _BASHUNIT_BASELINE_NAMES=("test_should_x") + _BASHUNIT_BASELINE_STATUSES=("failed") + + assert_successful_code "$(bashunit::baseline::contains "tests/foo_test.sh" "test_should_x" "failed" && echo $?)" +} + +function test_contains_returns_false_when_no_match() { + _BASHUNIT_BASELINE_FILES=("tests/foo_test.sh") + _BASHUNIT_BASELINE_NAMES=("test_should_x") + _BASHUNIT_BASELINE_STATUSES=("failed") + + local rc=0 + bashunit::baseline::contains "tests/foo_test.sh" "test_other" "failed" || rc=$? + + assert_not_equals "0" "$rc" +} + +function test_contains_returns_false_when_status_differs() { + _BASHUNIT_BASELINE_FILES=("tests/foo_test.sh") + _BASHUNIT_BASELINE_NAMES=("test_should_x") + _BASHUNIT_BASELINE_STATUSES=("failed") + + local rc=0 + bashunit::baseline::contains "tests/foo_test.sh" "test_should_x" "risky" || rc=$? + + assert_not_equals "0" "$rc" +} + +function test_contains_returns_false_for_empty_baseline() { + _BASHUNIT_BASELINE_FILES=() + _BASHUNIT_BASELINE_NAMES=() + _BASHUNIT_BASELINE_STATUSES=() + + local rc=0 + bashunit::baseline::contains "tests/foo_test.sh" "test_should_x" "failed" || rc=$? + + assert_not_equals "0" "$rc" +} From 138302cf828a81f56a969cd6a6138a839eb7c0e5 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 29 Apr 2026 13:59:29 +0200 Subject: [PATCH 2/2] fix(baseline): use sed for xml escape/unescape Bash 5.2+ enables patsub_replacement by default, which makes & in a parameter expansion replacement string refer to the matched text. That broke baseline entity encoding/decoding under recent Linux runners while still passing on macOS (Bash 3.2). Switch to sed for deterministic behavior across Bash versions. --- src/baseline.sh | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/baseline.sh b/src/baseline.sh index 46d6ef4a..263e9030 100644 --- a/src/baseline.sh +++ b/src/baseline.sh @@ -9,13 +9,15 @@ _BASHUNIT_BASELINE_STATUSES=() # Arguments: $1 - text ## function bashunit::baseline::__xml_escape_attr() { - local text="$1" - text="${text//&/&}" - text="${text///>}" - text="${text//\"/"}" - text="${text//\'/'}" - printf '%s' "$text" + # sed handles & and \" literals consistently across Bash versions; bash + # parameter expansion with patsub_replacement (5.2+) treats & in the + # replacement specially, which would corrupt entity strings. + printf '%s' "$1" \ + | sed -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' \ + -e "s/'/\'/g" } ## @@ -23,13 +25,13 @@ function bashunit::baseline::__xml_escape_attr() { # Arguments: $1 - text ## function bashunit::baseline::__xml_unescape_attr() { - local text="$1" - text="${text//</<}" - text="${text//>/>}" - text="${text//"/\"}" - text="${text//'/\'}" - text="${text//&/&}" - printf '%s' "$text" + # & must be decoded last so we don't double-decode. + printf '%s' "$1" \ + | sed -e 's/<//g' \ + -e 's/"/"/g' \ + -e "s/'/'/g" \ + -e 's/&/\&/g' } ##