diff --git a/src/assert/core.sh b/src/assert/core.sh index 6a57fc6f..ecc9d569 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -20,6 +20,18 @@ function bashunit::assert::should_skip() { bashunit::env::is_stop_on_assertion_failure_enabled && ((_BASHUNIT_ASSERTION_FAILED_IN_TEST)) } +# Emits a machine-detectable assertion usage error. The runner strips the +# prefix and reports the message through the existing Error channel. +function bashunit::assert::usage_error() { + local assertion=$1 + local required=$2 + local signature=$3 + local supplied=$4 + + printf 'bashunit: assertion usage error: %s expects %s arguments (%s), got %s\n' \ + "$assertion" "$required" "$signature" "$supplied" >&2 +} + _BASHUNIT_ASSERT_LABEL_OUT="" # Resolve assertion label into the slot _BASHUNIT_ASSERT_LABEL_OUT with no fork: @@ -207,6 +219,10 @@ function bashunit::handle_bool_assertion_failure() { function assert_same() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -222,6 +238,10 @@ function assert_same() { function assert_equals() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -242,6 +262,10 @@ function assert_equals() { function assert_not_equals() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -290,6 +314,10 @@ function assert_not_empty() { function assert_not_same() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -305,6 +333,10 @@ function assert_not_same() { function assert_contains() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local IFS=$' \t\n' local expected="$1" @@ -339,6 +371,10 @@ function bashunit::assert::_supports_nocasematch() { function assert_contains_ignore_case() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -396,6 +432,10 @@ function assert_contains_ignore_case() { function assert_not_contains() { local label_override="" bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local IFS=$' \t\n' local expected="$1" @@ -416,6 +456,10 @@ function assert_not_contains() { function assert_matches() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "pattern, actual" "$#" + return 2 + fi local IFS=$' \t\n' local expected="$1" @@ -442,6 +486,10 @@ function assert_matches() { function assert_not_matches() { local label_override="" bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "pattern, actual" "$#" + return 2 + fi local IFS=$' \t\n' local expected="$1" @@ -706,6 +754,10 @@ function assert_command_not_found() { function assert_string_starts_with() { local label_override="" bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local IFS=$' \t\n' local expected="$1" @@ -727,6 +779,10 @@ function assert_string_starts_with() { function assert_string_not_starts_with() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -745,6 +801,10 @@ function assert_string_not_starts_with() { function assert_string_ends_with() { local label_override="" bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local IFS=$' \t\n' local expected="$1" @@ -767,6 +827,10 @@ function assert_string_ends_with() { function assert_string_not_ends_with() { local label_override="" bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local IFS=$' \t\n' local expected="$1" @@ -787,6 +851,10 @@ function assert_string_not_ends_with() { function assert_less_than() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -802,6 +870,10 @@ function assert_less_than() { function assert_less_or_equal_than() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -817,6 +889,10 @@ function assert_less_or_equal_than() { function assert_greater_than() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -832,6 +908,10 @@ function assert_greater_than() { function assert_greater_or_equal_than() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -868,6 +948,10 @@ function bashunit::assert::_is_numeric() { ## function assert_within_delta() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 3 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "expected, actual, delta" "$#" + return 2 + fi local expected="$1" local actual="$2" @@ -948,6 +1032,10 @@ function assert_within_delta() { function assert_line_count() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" + return 2 + fi local IFS=$' \t\n' local expected="$1" @@ -1029,6 +1117,10 @@ function bashunit::format_to_regex() { function assert_string_matches_format() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "format, actual" "$#" + return 2 + fi local format="$1" local actual="$2" @@ -1047,6 +1139,10 @@ function assert_string_matches_format() { function assert_string_not_matches_format() { bashunit::assert::should_skip && return 0 + if [ "$#" -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "format, actual" "$#" + return 2 + fi local format="$1" local actual="$2" diff --git a/src/runner/diagnostics.sh b/src/runner/diagnostics.sh index 7f8be78d..acf66b27 100644 --- a/src/runner/diagnostics.sh +++ b/src/runner/diagnostics.sh @@ -31,12 +31,50 @@ function bashunit::runner::halt_if_stop_on_failure() { } # Writes the detected runtime-error message (empty when none) into -# _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT. Return-slot form avoids a per-test fork +# _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT and display-safe output into +# _BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT. Return-slot form avoids a per-test fork # on the hot path (#764). # Arguments: $1 runtime_output function bashunit::runner::detect_runtime_error() { local runtime_output=$1 _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="" + _BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT=$runtime_output + + local usage_prefix="bashunit: assertion usage error: " + local usage_marker=$'\n'"$usage_prefix" + local usage_before="" + local usage_rest="" + local usage_found=false + case "$runtime_output" in + "$usage_prefix"*) + usage_rest=${runtime_output#"$usage_prefix"} + usage_found=true + ;; + *"$usage_marker"*) + usage_before=${runtime_output%%"$usage_marker"*} + usage_rest=${runtime_output#*"$usage_marker"} + usage_found=true + ;; + esac + + if [ "$usage_found" = true ]; then + local usage_error=${usage_rest%%$'\n'*} + local usage_after="" + if [ "$usage_rest" != "$usage_error" ]; then + usage_after=${usage_rest#*$'\n'} + fi + _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT=$usage_error + if [ -n "$usage_before" ] && [ -n "$usage_after" ]; then + _BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT="$usage_before +$usage_after" + elif [ -n "$usage_before" ]; then + _BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT=$usage_before + else + _BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT=$usage_after + fi + return + fi + case "$runtime_output" in *"command not found"* | *"unbound variable"* | *"permission denied"* | \ *"no such file or directory"* | *"syntax error"* | *"bad substitution"* | \ diff --git a/src/runner/exec.sh b/src/runner/exec.sh index a8e562e5..8f420eef 100644 --- a/src/runner/exec.sh +++ b/src/runner/exec.sh @@ -326,6 +326,7 @@ function bashunit::runner::run_test() { local attempt_runtime_output="${test_execution_result%%##ASSERTIONS_*}" bashunit::runner::detect_runtime_error "$attempt_runtime_output" local attempt_runtime_error=$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT + local attempt_display_output=$_BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT bashunit::runner::extract_result_counts "$test_execution_result" # Mirror the commit-phase failure test exactly (runtime error, non-zero exit, # or a failed assertion); snapshot/incomplete/skipped/risky are not failures. @@ -373,7 +374,7 @@ function bashunit::runner::run_test() { # Reuse the final attempt's values (the loop always runs at least once and # its locals persist in this function scope), instead of recomputing and # forking detect_runtime_error a second time (#764). - local runtime_output=$attempt_runtime_output + local runtime_output=$attempt_display_output local runtime_error=$attempt_runtime_error # parse_result accumulates _BASHUNIT_TEST_EXIT_CODE; reset it so each test's diff --git a/tests/acceptance/bashunit_assert_arity_test.sh b/tests/acceptance/bashunit_assert_arity_test.sh new file mode 100644 index 00000000..8aa18b05 --- /dev/null +++ b/tests/acceptance/bashunit_assert_arity_test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +function test_missing_assertion_argument_is_a_usage_error() { + local fixture=tests/acceptance/fixtures/assert_arity/missing.sh + local output exit_code=0 + + output=$(NO_COLOR=1 ./bashunit --no-parallel --skip-env-file "$fixture" 2>&1) || exit_code=$? + + assert_same 1 "$exit_code" + assert_contains "✗ Error: Wrong arg count" "$output" + assert_contains "assert_same expects 2 arguments (expected, actual), got 1" "$output" + assert_not_contains "but got" "$output" +} diff --git a/tests/acceptance/bashunit_strict_mode_test.sh b/tests/acceptance/bashunit_strict_mode_test.sh index 289ca748..cf3b8a6f 100644 --- a/tests/acceptance/bashunit_strict_mode_test.sh +++ b/tests/acceptance/bashunit_strict_mode_test.sh @@ -44,14 +44,15 @@ function test_strict_mode_fails_on_unset_variable_in_set_up() { assert_contains "failed" "$output" } -function test_strict_mode_reports_an_omitted_variadic_actual_as_a_plain_failure() { +function test_strict_mode_reports_an_omitted_variadic_actual_as_a_usage_error() { local output output=$(BASHUNIT_STRICT_MODE=true ./bashunit --no-parallel --simple --skip-env-file --env "$TEST_ENV_FILE" \ tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh 2>&1) || true assert_not_contains "unbound variable" "$output" - assert_contains "to contain" "$output" - assert_contains "to start with" "$output" + assert_contains "✗ Error" "$output" + assert_contains "assert_contains expects 2 arguments (expected, actual), got 1" "$output" + assert_contains "assert_string_starts_with expects 2 arguments (expected, actual), got 1" "$output" } function test_cli_flag_overrides_env_var() { diff --git a/tests/acceptance/fixtures/assert_arity/missing.sh b/tests/acceptance/fixtures/assert_arity/missing.sh new file mode 100644 index 00000000..194c867b --- /dev/null +++ b/tests/acceptance/fixtures/assert_arity/missing.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +function test_wrong_arg_count() { + assert_same "only-one" + assert_same "later assertion" "later assertion" +} diff --git a/tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh b/tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh index 0a213021..b9395d52 100644 --- a/tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh +++ b/tests/acceptance/fixtures/strict_mode_variadic_actual_omitted.sh @@ -2,8 +2,8 @@ # The "actual" operand of these assertions is variadic ("${@:2}"), so omitting # it leaves an empty array. Expanding an empty array under `set -u` (--strict) -# is an unbound-variable error on Bash < 4.4, which used to abort the test with -# an internal error instead of reporting a normal assertion failure. +# is an unbound-variable error on Bash < 4.4, which used to abort the test. The +# assertions now reject the missing operand as a usage error first. function test_contains_without_actual() { assert_contains "needle" } diff --git a/tests/unit/assert/arity_test.sh b/tests/unit/assert/arity_test.sh new file mode 100644 index 00000000..285f087c --- /dev/null +++ b/tests/unit/assert/arity_test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# @data_provider provide_core_comparison_assertions +function test_core_comparison_assertions_reject_missing_arguments() { + local assertion=$1 + local required=$2 + local signature=$3 + local output exit_code=0 + + if [ "$required" -eq 3 ]; then + output=$("$assertion" "first" "second" 2>&1) || exit_code=$? + else + output=$("$assertion" "first" 2>&1) || exit_code=$? + fi + + assert_same 2 "$exit_code" + assert_same \ + "bashunit: assertion usage error: $assertion expects $required arguments ($signature), got $((required - 1))" \ + "$output" +} + +function provide_core_comparison_assertions() { + bashunit::data_set assert_same 2 "expected, actual" + bashunit::data_set assert_equals 2 "expected, actual" + bashunit::data_set assert_not_same 2 "expected, actual" + bashunit::data_set assert_not_equals 2 "expected, actual" + bashunit::data_set assert_contains 2 "expected, actual" + bashunit::data_set assert_contains_ignore_case 2 "expected, actual" + bashunit::data_set assert_not_contains 2 "expected, actual" + bashunit::data_set assert_matches 2 "pattern, actual" + bashunit::data_set assert_not_matches 2 "pattern, actual" + bashunit::data_set assert_string_starts_with 2 "expected, actual" + bashunit::data_set assert_string_not_starts_with 2 "expected, actual" + bashunit::data_set assert_string_ends_with 2 "expected, actual" + bashunit::data_set assert_string_not_ends_with 2 "expected, actual" + bashunit::data_set assert_less_than 2 "expected, actual" + bashunit::data_set assert_less_or_equal_than 2 "expected, actual" + bashunit::data_set assert_greater_than 2 "expected, actual" + bashunit::data_set assert_greater_or_equal_than 2 "expected, actual" + bashunit::data_set assert_within_delta 3 "expected, actual, delta" + bashunit::data_set assert_line_count 2 "expected, actual" + bashunit::data_set assert_string_matches_format 2 "format, actual" + bashunit::data_set assert_string_not_matches_format 2 "format, actual" +} + +function test_empty_values_still_count_as_supplied_arguments() { + assert_same "" "" + assert_equals "" "" + assert_contains "" "" +} diff --git a/tests/unit/runner/diagnostics_test.sh b/tests/unit/runner/diagnostics_test.sh index 806cccbf..94f25cc2 100644 --- a/tests/unit/runner/diagnostics_test.sh +++ b/tests/unit/runner/diagnostics_test.sh @@ -43,6 +43,24 @@ function test_detect_runtime_error_matches_unexpected_eof() { assert_same "line 5: unexpected EOF while looking for matching" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" } +function test_detect_runtime_error_extracts_and_hides_assertion_usage_marker() { + local input=$'before\nbashunit: assertion usage error: assert_same expects 2 arguments, got 1\nafter' + + bashunit::runner::detect_runtime_error "$input" + + assert_same "assert_same expects 2 arguments, got 1" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" + assert_same $'before\nafter' "$_BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT" +} + +function test_detect_runtime_error_ignores_indented_assertion_usage_marker() { + local input=" bashunit: assertion usage error: nested output" + + bashunit::runner::detect_runtime_error "$input" + + assert_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" + assert_same "$input" "$_BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT" +} + function test_classify_kill_signal_sigkill_mentions_oom() { local output output="$(bashunit::runner::classify_kill_signal 137)"