From 53cf910902254f7b8d9425edf5477f22486dc6e2 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 29 Oct 2026 23:04:31 +0100 Subject: [PATCH 1/7] fix(provider): isolate malformed argument rows Closes #1419 --- CHANGELOG.md | 2 + docs/data-providers.md | 4 +- src/runner/exec.sh | 15 ++++- src/runner/provider.sh | 50 ++++++++--------- .../bashunit_provider_transport_test.sh | 56 +++++++++++++++++++ tests/functional/provider_test.sh | 30 ++++++++++ 6 files changed, 127 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067c59326..0038434b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Echo data providers preserve words containing `#` or parentheses and report malformed rows without dropping later rows (#1419) + - `init` writes the bootstrap path and a custom tests directory to `.env` quoted, so a directory with a space runs afterwards instead of breaking every next run (#1411) - Failure source context stays inside a one-line test instead of listing the next function's assertions (#1420) - Parallel runs count tests under relative hidden paths and keep their results separate from matching visible paths (#1393) diff --git a/docs/data-providers.md b/docs/data-providers.md index f21b7981d..bc44eb527 100644 --- a/docs/data-providers.md +++ b/docs/data-providers.md @@ -81,7 +81,9 @@ function provider_function() { > **Note**: The previous variant of using `echo` to define data within a data > provider is still supported but deprecated, as it does not support empty values or -> values containing spaces. Use `bashunit::data_set` instead. +> values containing spaces. Use `bashunit::data_set` instead. A `#` word or literal +> parentheses no longer abort a row. An unclosed quote fails only that row, +> with its provider name and row number; later rows still run. ## Interpolating arguments in test names diff --git a/src/runner/exec.sh b/src/runner/exec.sh index c9e9a11c3..1cf533e5b 100644 --- a/src/runner/exec.sh +++ b/src/runner/exec.sh @@ -214,11 +214,12 @@ function bashunit::runner::call_test_functions() { continue fi - local data + local data provider_row=0 for data in "${provider_data[@]+"${provider_data[@]}"}"; do + provider_row=$((provider_row + 1)) parsed_data=() parsed_data_count=0 - local transport_error="" + local transport_error="" parse_error=false if ! bashunit::env::ensure_run_output_dir; then transport_error="argument storage could not be created" elif [ -z "$provider_arg_file" ] || [ ! -f "$provider_arg_file" ]; then @@ -246,7 +247,12 @@ function bashunit::runner::call_test_functions() { transport_error="arguments could not be read" fi else - transport_error="arguments could not be written" + if [ "$?" -eq 2 ]; then + parse_error=true + transport_error="row $provider_row could not be parsed: $data" + else + transport_error="arguments could not be written" + fi fi fi if [ -n "$transport_error" ]; then @@ -254,6 +260,9 @@ function bashunit::runner::call_test_functions() { _BASHUNIT_RUNNER_RESULT_ORDINAL=$_test_ordinal bashunit::runner::report_provider_error \ "$script" "$fn_name" "data provider '$_BASHUNIT_PROVIDER_FN_OUT' $transport_error" + if [ "$parse_error" = true ]; then + continue + fi break fi _test_ordinal=$((_test_ordinal + 1)) diff --git a/src/runner/provider.sh b/src/runner/provider.sh index 4f91b1557..19cffb0bc 100644 --- a/src/runner/provider.sh +++ b/src/runner/provider.sh @@ -23,39 +23,34 @@ function bashunit::runner::parse_data_provider_args() { has_metachar=true fi - # An odd run of trailing backslashes escapes the `)` the eval below adds, so - # eval sees `args=(C:\)` -- unterminated, a SYNTAX error rather than a failed - # command. Inside a command substitution, which is how exec.sh calls this, - # that kills the subshell: no output, and the argument reaches the test unset - # instead of as `C:` (#1134). The fallback parser handles it, so skip eval. + # An odd trailing backslash escapes eval's closing parenthesis. local trailing="${input##*[!\\]}" if [ $((${#trailing} % 2)) -eq 1 ]; then has_metachar=true fi - # Try eval first (needed for $'...' from printf '%q'), unless metacharacters present - if [ "$has_metachar" = false ] && eval "args=($input)" 2>/dev/null; then - # Check if args has elements after eval - args_count=0 + # Syntax errors in eval exit its shell; serialize there so expansions run only once. + if [ "$has_metachar" = false ] && ( + local -a eval_args=() + local eval_args_count=0 + eval "eval_args=($input)" 2>/dev/null || exit 1 local _tmp arg - for _tmp in ${args+"${args[@]}"}; do args_count=$((args_count + 1)); done - if [ "$args_count" -gt 0 ]; then - # Successfully parsed - remove sentinel if present - local last_idx=$((args_count - 1)) - if [ -z "${args[$last_idx]}" ]; then - unset 'args[$last_idx]' - fi - # Print args and return early - for arg in "${args[@]+"${args[@]}"}"; do - if [ "$output_format" = nul ]; then - printf '%s\0' "$arg" - else - encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" - printf '%s\n' "$encoded_arg" - fi - done - return + for _tmp in ${eval_args+"${eval_args[@]}"}; do eval_args_count=$((eval_args_count + 1)); done + [ "$eval_args_count" -gt 0 ] || exit 1 + local last_idx=$((eval_args_count - 1)) + if [ -z "${eval_args[$last_idx]}" ]; then + unset 'eval_args[$last_idx]' fi + for arg in "${eval_args[@]+"${eval_args[@]}"}"; do + if [ "$output_format" = nul ]; then + printf '%s\0' "$arg" + else + encoded_arg="$(bashunit::helper::encode_base64 "${arg}")" + printf '%s\n' "$encoded_arg" + fi + done + ); then + return 0 fi # Fallback: parse args from the input string into an array, respecting quotes and escapes @@ -110,6 +105,9 @@ function bashunit::runner::parse_data_provider_args() { current_arg="$current_arg$char" fi done + if [ "$in_quotes" = true ]; then + return 2 + fi args[args_count]="$current_arg" args_count=$((args_count + 1)) # Remove all trailing empty strings diff --git a/tests/acceptance/bashunit_provider_transport_test.sh b/tests/acceptance/bashunit_provider_transport_test.sh index 7602b98c5..6425864e1 100644 --- a/tests/acceptance/bashunit_provider_transport_test.sh +++ b/tests/acceptance/bashunit_provider_transport_test.sh @@ -245,3 +245,59 @@ TEST done assert_same 0 "$leftover" } + +# @data_provider provide_transport_modes +function test_echo_hash_rows_keep_the_header_and_summary_counts() { + local mode="$1" dir + dir="$(bashunit::temp_dir)" + cat >"$dir/hash_test.sh" <<'TEST' +function provide_hash_rows() { echo 'color #fff'; echo 'price 5'; echo 'size 10'; } +# @data_provider provide_hash_rows +function test_hash_words() { assert_same 2 "$#"; } +TEST + local output code=0 + output=$(./bashunit "$mode" --skip-env-file --no-color "$dir/hash_test.sh" 2>&1) || code=$? + assert_same 0 "$code" + assert_contains 'Tests: 3' "$output" + assert_contains '3 passed' "$output" + assert_contains '3 total' "$output" +} + +# @data_provider provide_transport_modes +function test_a_malformed_provider_row_does_not_drop_later_rows() { + local mode="$1" dir + dir="$(bashunit::temp_dir)" + cat >"$dir/malformed_test.sh" <<'TEST' +function provide_malformed_rows() { echo 'before 1'; echo 'broken "quote'; echo 'after 2'; } +# @data_provider provide_malformed_rows +function test_malformed_words() { assert_same 2 "$#"; } +TEST + local output code=0 + output=$(./bashunit "$mode" --skip-env-file --no-color \ + --report-json "$dir/report.json" "$dir/malformed_test.sh" 2>&1) || code=$? + assert_same 1 "$code" + assert_contains "data provider 'provide_malformed_rows' row 2 could not be parsed: broken \"quote" "$output" + assert_not_contains 'arguments could not be written' "$output" + assert_contains 'Tests: 3' "$output" + assert_contains '2 passed' "$output" + assert_contains '1 failed' "$output" + assert_contains '3 total' "$output" + assert_file_contains "$dir/report.json" '"total": 3, "passed": 2, "failed": 1' +} + +# @data_provider provide_transport_modes +function test_echo_provider_expansion_runs_only_once_per_row() { + local mode="$1" dir + dir="$(bashunit::temp_dir)" + cat >"$dir/expansion_test.sh" <<'TEST' +function expand_provider_word() { printf x >>"$PROVIDER_EXPANSION_CALLS"; printf expanded; } +function provide_expansion_rows() { echo 'value `expand_provider_word`'; } +# @data_provider provide_expansion_rows +function test_expansion_words() { assert_same 2 "$#"; assert_same expanded "$2"; } +TEST + local output code=0 + output=$(PROVIDER_EXPANSION_CALLS="$dir/calls" ./bashunit "$mode" --skip-env-file --no-color \ + "$dir/expansion_test.sh" 2>&1) || code=$? + assert_same 0 "$code" + assert_same x "$(<"$dir/calls")" +} diff --git a/tests/functional/provider_test.sh b/tests/functional/provider_test.sh index 424f98f79..0089f4339 100644 --- a/tests/functional/provider_test.sh +++ b/tests/functional/provider_test.sh @@ -188,3 +188,33 @@ function test_provider_eval_does_not_assign_in_runner() { assert_same changed "$1" assert_same unset "${PROVIDER_EVAL_SIDE_EFFECT-unset}" } + +function provide_echo_hash_and_parentheses() { + echo 'color #fff' + echo 'shape (round)' + echo 'size 10' +} + +# @data_provider provide_echo_hash_and_parentheses +function test_echo_provider_keeps_hash_and_parentheses_words() { + assert_same 2 "$#" + case "$1" in + color) assert_same '#fff' "$2" ;; + shape) assert_same '(round)' "$2" ;; + size) assert_same 10 "$2" ;; + esac +} + +function provide_quoted_hash_and_parentheses() { + bashunit::data_set hash $'first\r#fff' + bashunit::data_set parentheses $'first\r(round)' +} + +# @data_provider provide_quoted_hash_and_parentheses +function test_ansi_quoted_provider_values_keep_control_characters_next_to_metacharacters() { + assert_same 2 "$#" + case "$1" in + hash) assert_same $'first\r#fff' "$2" ;; + parentheses) assert_same $'first\r(round)' "$2" ;; + esac +} From 3386c1b55394f2163e73a5982b04a99949e055aa Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 29 Oct 2026 23:13:18 +0100 Subject: [PATCH 2/7] test(provider): isolate CR parser coverage --- tests/functional/provider_test.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/functional/provider_test.sh b/tests/functional/provider_test.sh index 0089f4339..e1e7d376a 100644 --- a/tests/functional/provider_test.sh +++ b/tests/functional/provider_test.sh @@ -206,15 +206,20 @@ function test_echo_provider_keeps_hash_and_parentheses_words() { } function provide_quoted_hash_and_parentheses() { - bashunit::data_set hash $'first\r#fff' - bashunit::data_set parentheses $'first\r(round)' + bashunit::data_set hash $'first\v#fff' + bashunit::data_set parentheses $'first\v(round)' } # @data_provider provide_quoted_hash_and_parentheses function test_ansi_quoted_provider_values_keep_control_characters_next_to_metacharacters() { assert_same 2 "$#" case "$1" in - hash) assert_same $'first\r#fff' "$2" ;; - parentheses) assert_same $'first\r(round)' "$2" ;; + hash) assert_same $'first\v#fff' "$2" ;; + parentheses) assert_same $'first\v(round)' "$2" ;; esac } + +function test_ansi_quoted_parser_values_keep_carriage_returns_next_to_metacharacters() { + assert_same "Zmlyc3QNI2ZmZg==" "$(bashunit::runner::parse_data_provider_args "\$'first\\r#fff' ''")" + assert_same "Zmlyc3QNKHJvdW5kKQ==" "$(bashunit::runner::parse_data_provider_args "\$'first\\r(round)' ''")" +} From d66c89adbc62d2b39601f1bbd0018d71d7080707 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 29 Oct 2026 23:35:02 +0100 Subject: [PATCH 3/7] fix(provider): stop after malformed rows --- src/runner/exec.sh | 4 ++++ .../bashunit_provider_transport_test.sh | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/runner/exec.sh b/src/runner/exec.sh index db7d86113..70f0cc665 100644 --- a/src/runner/exec.sh +++ b/src/runner/exec.sh @@ -265,6 +265,10 @@ function bashunit::runner::call_test_functions() { bashunit::runner::report_provider_error \ "$script" "$fn_name" "data provider '$_BASHUNIT_PROVIDER_FN_OUT' $transport_error" if [ "$parse_error" = true ]; then + bashunit::runner::halt_if_stop_on_failure + if bashunit::env::is_stop_on_failure_enabled; then + break + fi continue fi break diff --git a/tests/acceptance/bashunit_provider_transport_test.sh b/tests/acceptance/bashunit_provider_transport_test.sh index 6425864e1..a68fcf3d6 100644 --- a/tests/acceptance/bashunit_provider_transport_test.sh +++ b/tests/acceptance/bashunit_provider_transport_test.sh @@ -301,3 +301,26 @@ TEST assert_same 0 "$code" assert_same x "$(<"$dir/calls")" } + +# @data_provider provide_transport_modes +function test_a_malformed_provider_row_honors_stop_on_failure() { + local mode="$1" dir + dir="$(bashunit::temp_dir)" + cat >"$dir/stop_parse_test.sh" <<'TEST' +function provide_stop_parse_rows() { echo 'broken "quote'; echo 'after 2'; } +function tear_down_after_script() { printf done >"$STOP_PARSE_TEARDOWN"; } +# @data_provider provide_stop_parse_rows +function test_stop_parse_words() { printf ran >"$STOP_PARSE_BODY"; assert_same 2 "$#"; } +TEST + local output code=0 + output=$(STOP_PARSE_BODY="$dir/body" STOP_PARSE_TEARDOWN="$dir/teardown" \ + ./bashunit "$mode" --stop-on-failure --skip-env-file --no-color \ + --report-json "$dir/report.json" "$dir/stop_parse_test.sh" 2>&1) || code=$? + assert_same 1 "$code" + assert_contains "data provider 'provide_stop_parse_rows' row 1 could not be parsed" "$output" + assert_contains '1 failed' "$output" + assert_contains '1 total' "$output" + assert_file_not_exists "$dir/body" + assert_file_contains "$dir/teardown" "done" + assert_file_contains "$dir/report.json" '"total": 1, "passed": 0, "failed": 1' +} From bf5f4bb06d95ac5933e0670d35249fb182097ada Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 30 Oct 2026 02:18:08 +0100 Subject: [PATCH 4/7] test(provider): preserve carriage returns on windows --- tests/functional/provider_test.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/functional/provider_test.sh b/tests/functional/provider_test.sh index e1e7d376a..b2cf0ec76 100644 --- a/tests/functional/provider_test.sh +++ b/tests/functional/provider_test.sh @@ -220,6 +220,8 @@ function test_ansi_quoted_provider_values_keep_control_characters_next_to_metach } function test_ansi_quoted_parser_values_keep_carriage_returns_next_to_metacharacters() { - assert_same "Zmlyc3QNI2ZmZg==" "$(bashunit::runner::parse_data_provider_args "\$'first\\r#fff' ''")" - assert_same "Zmlyc3QNKHJvdW5kKQ==" "$(bashunit::runner::parse_data_provider_args "\$'first\\r(round)' ''")" + local hash_input="\$'first\\r#fff' ''" + local parentheses_input="\$'first\\r(round)' ''" + assert_same "Zmlyc3QNI2ZmZg==" "$(bashunit::runner::parse_data_provider_args "$hash_input")" + assert_same "Zmlyc3QNKHJvdW5kKQ==" "$(bashunit::runner::parse_data_provider_args "$parentheses_input")" } From a1fb62f713bb8f93d3b110d0bbf7568c66300bb3 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 30 Oct 2026 02:46:32 +0100 Subject: [PATCH 5/7] fix(provider): preserve quoted carriage returns on windows --- CHANGELOG.md | 2 +- src/runner/provider.sh | 9 ++++++--- tests/functional/provider_test.sh | 4 ++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d52212c89..b4073e55f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- Echo data providers preserve words containing `#` or parentheses and report malformed rows without dropping later rows (#1419) +- Echo data providers keep `#` and parentheses, and report malformed rows without dropping later rows. Quoted values keep carriage returns on Windows (#1419) - A green file or filtered subset preserves rerun-cache entries for tests it did not execute (#1418) - SIGTERM and SIGHUP run teardown hooks, stop parallel workers, and remove the run's scratch files before exiting with status 1 (#1417) - Sequential stop-on-failure runs write requested reports and the rerun cache for the tests that ran (#1415) diff --git a/src/runner/provider.sh b/src/runner/provider.sh index 19cffb0bc..bc64ee018 100644 --- a/src/runner/provider.sh +++ b/src/runner/provider.sh @@ -33,9 +33,12 @@ function bashunit::runner::parse_data_provider_args() { if [ "$has_metachar" = false ] && ( local -a eval_args=() local eval_args_count=0 - eval "eval_args=($input)" 2>/dev/null || exit 1 - local _tmp arg - for _tmp in ${eval_args+"${eval_args[@]}"}; do eval_args_count=$((eval_args_count + 1)); done + local arg + # MSYS reparses compound array assignments and drops expanded carriage returns. + eval 'for arg in '"$input"'; do + eval_args[eval_args_count]="$arg" + eval_args_count=$((eval_args_count + 1)) + done' 2>/dev/null || exit 1 [ "$eval_args_count" -gt 0 ] || exit 1 local last_idx=$((eval_args_count - 1)) if [ -z "${eval_args[$last_idx]}" ]; then diff --git a/tests/functional/provider_test.sh b/tests/functional/provider_test.sh index b2cf0ec76..31b5fcb10 100644 --- a/tests/functional/provider_test.sh +++ b/tests/functional/provider_test.sh @@ -208,6 +208,8 @@ function test_echo_provider_keeps_hash_and_parentheses_words() { function provide_quoted_hash_and_parentheses() { bashunit::data_set hash $'first\v#fff' bashunit::data_set parentheses $'first\v(round)' + bashunit::data_set hash_cr $'first\r#fff' + bashunit::data_set parentheses_cr $'first\r(round)' } # @data_provider provide_quoted_hash_and_parentheses @@ -216,6 +218,8 @@ function test_ansi_quoted_provider_values_keep_control_characters_next_to_metach case "$1" in hash) assert_same $'first\v#fff' "$2" ;; parentheses) assert_same $'first\v(round)' "$2" ;; + hash_cr) assert_same $'first\r#fff' "$2" ;; + parentheses_cr) assert_same $'first\r(round)' "$2" ;; esac } From f45915ec3afd7bd6ecdbe2b7df2b5be178e4918d Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 30 Oct 2026 07:39:19 +0100 Subject: [PATCH 6/7] fix(ci): isolate docs checks by pull request --- .github/workflows/check-docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index a844bdf46..5c2bac50b 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -9,8 +9,8 @@ on: workflow_dispatch: concurrency: - group: pages - cancel-in-progress: false + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: build: From 23469b06d0fc2c9848ab11b4cc6d2056a254376e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 30 Oct 2026 07:40:57 +0100 Subject: [PATCH 7/7] test(ci): use an executable installer fixture --- tests/acceptance/install_test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/install_test.sh b/tests/acceptance/install_test.sh index 3017210b9..5fc751576 100644 --- a/tests/acceptance/install_test.sh +++ b/tests/acceptance/install_test.sh @@ -233,7 +233,7 @@ fi case "$INSTALL_TEST_DOWNLOAD_RESULT" in failure) printf 'partial download\n' >"$target"; exit 22 ;; empty) : >"$target" ;; - *) printf 'new binary\n' >"$target" ;; + *) printf '#!/bin/sh\nexit 0\n' >"$target" ;; esac SH chmod +x shims/curl @@ -249,7 +249,7 @@ SH if [ "$result" = success ]; then assert_successful_code "" "" "$status" - assert_same 'new binary' "$(cat destination/bashunit)" + assert_same "$(printf '#!/bin/sh\nexit 0')" "$(cat destination/bashunit)" assert_is_file_executable destination/bashunit assert_not_empty "$(find destination/bashunit -perm 0744 -print)" else