From e43606db84d95e9d49f82442b7b773681425a2a6 Mon Sep 17 00:00:00 2001 From: Morax Date: Sun, 9 Aug 2026 20:04:31 +0200 Subject: [PATCH 1/4] feat(assert): add assert_command_available --- CHANGELOG.md | 1 + README.md | 2 +- completions/_bashunit | 2 +- completions/bashunit.bash | 2 +- docs/assertions.md | 29 ++++++++++++- docs/public/bashunit-skill.md | 2 +- src/assert/core.sh | 23 ++++++++++ ...it_should_display_all_assert_docs.snapshot | 12 ++++++ tests/unit/assert/advanced_test.sh | 43 +++++++++++++++++++ tests/unit/assert/arity_test.sh | 11 +++-- 10 files changed, 118 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d89f7a9..afbd1875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `--list` (alias `--dry-run`) prints the tests a run would execute, without running them; `--list-format json` emits file, function, name, line and tags. Honours every selection flag, including `--shard` and `--random-order --seed` ordering (#1007) - `# @tags a b` above any top-level line applies those tags to every test in the file, unioned with per-function `# @tag` (#1008) - `--tag` accepts expressions: `'a&&b'` (AND) and `'!a'` (NOT), combinable as `'a&&!b'`. Repeated `--tag` flags keep OR semantics, and `--exclude-tag` still wins (#1008) +- `assert_command_available ` asserts that an external command, shell builtin or function resolves through `command -v` (#1027) - The coverage engine in use is reported by `--verbose`, and an explicit `BASHUNIT_COVERAGE_ENGINE=xtrace` that the running Bash cannot honour now warns instead of being silently ignored (#1005) ### Changed diff --git a/README.md b/README.md index e46dc2df..78f8754b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ## Why bashunit A lightweight, fast testing framework for **Bash 3.0+**, focused on developer experience. -It ships 73 assertions plus spies, mocks, data providers, snapshots and more. +It ships 75 assertions plus spies, mocks, data providers, snapshots and more. ## Quick start diff --git a/completions/_bashunit b/completions/_bashunit index 8202c39f..62724ebf 100644 --- a/completions/_bashunit +++ b/completions/_bashunit @@ -17,7 +17,7 @@ _bashunit() { assert_fns=( assert_array_contains assert_array_length assert_array_not_contains assert_arrays_equal assert_assertion_fails assert_assertion_fails_with - assert_assertion_passes assert_command_not_found assert_contains + assert_assertion_passes assert_command_available assert_command_not_found assert_contains assert_contains_ignore_case assert_date_after assert_date_before assert_date_equals assert_date_within_delta assert_date_within_range assert_directory_exists assert_directory_not_exists assert_duration diff --git a/completions/bashunit.bash b/completions/bashunit.bash index 2836bb4f..0f2aa29b 100644 --- a/completions/bashunit.bash +++ b/completions/bashunit.bash @@ -30,7 +30,7 @@ _BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --coverage --coverage-exclude \ _BASHUNIT_COMPLETIONS_ASSERT_FNS="assert_array_contains assert_array_length \ assert_array_not_contains assert_arrays_equal assert_assertion_fails \ -assert_assertion_fails_with assert_assertion_passes assert_command_not_found \ +assert_assertion_fails_with assert_assertion_passes assert_command_available assert_command_not_found \ assert_contains assert_contains_ignore_case assert_date_after \ assert_date_before assert_date_equals assert_date_within_delta \ assert_date_within_range assert_directory_exists assert_directory_not_exists \ diff --git a/docs/assertions.md b/docs/assertions.md index de9c15f2..ca68e457 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -22,7 +22,7 @@ to narrow it (`bashunit doc json`). | **Strings** | [assert_contains](#assert-contains) · [assert_not_contains](#assert-not-contains) · [assert_contains_ignore_case](#assert-contains-ignore-case) · [assert_matches](#assert-matches) · [assert_not_matches](#assert-not-matches) · [assert_string_starts_with](#assert-string-starts-with) · [assert_string_not_starts_with](#assert-string-not-starts-with) · [assert_string_ends_with](#assert-string-ends-with) · [assert_string_not_ends_with](#assert-string-not-ends-with) · [assert_string_matches_format](#assert-string-matches-format) · [assert_string_not_matches_format](#assert-string-not-matches-format) · [assert_empty](#assert-empty) · [assert_not_empty](#assert-not-empty) · [assert_line_count](#assert-line-count) | | **Numbers** | [assert_less_than](#assert-less-than) · [assert_less_or_equal_than](#assert-less-or-equal-than) · [assert_greater_than](#assert-greater-than) · [assert_greater_or_equal_than](#assert-greater-or-equal-than) · [assert_within_delta](#assert-within-delta) | | **Dates** | [assert_date_equals](#assert-date-equals) · [assert_date_before](#assert-date-before) · [assert_date_after](#assert-date-after) · [assert_date_within_range](#assert-date-within-range) · [assert_date_within_delta](#assert-date-within-delta) | -| **Exit codes and commands** | [assert_exit_code](#assert-exit-code) · [assert_successful_code](#assert-successful-code) · [assert_unsuccessful_code](#assert-unsuccessful-code) · [assert_general_error](#assert-general-error) · [assert_command_not_found](#assert-command-not-found) · [assert_exec](#assert-exec) | +| **Exit codes and commands** | [assert_exit_code](#assert-exit-code) · [assert_successful_code](#assert-successful-code) · [assert_unsuccessful_code](#assert-unsuccessful-code) · [assert_general_error](#assert-general-error) · [assert_command_available](#assert-command-available) · [assert_command_not_found](#assert-command-not-found) · [assert_exec](#assert-exec) | | **Files** | [assert_file_exists](#assert-file-exists) · [assert_file_not_exists](#assert-file-not-exists) · [assert_file_contains](#assert-file-contains) · [assert_file_not_contains](#assert-file-not-contains) · [assert_is_file](#assert-is-file) · [assert_is_file_empty](#assert-is-file-empty) · [assert_is_symlink](#assert-is-symlink) · [assert_is_not_symlink](#assert-is-not-symlink) · [assert_symlink_to](#assert-symlink-to) · [assert_file_permissions](#assert-file-permissions) · [assert_files_equals](#assert-files-equals) · [assert_files_not_equals](#assert-files-not-equals) | | **Directories** | [assert_directory_exists](#assert-directory-exists) · [assert_directory_not_exists](#assert-directory-not-exists) · [assert_is_directory](#assert-is-directory) · [assert_is_directory_empty](#assert-is-directory-empty) · [assert_is_directory_not_empty](#assert-is-directory-not-empty) · [assert_is_directory_readable](#assert-is-directory-readable) · [assert_is_directory_not_readable](#assert-is-directory-not-readable) · [assert_is_directory_writable](#assert-is-directory-writable) · [assert_is_directory_not_writable](#assert-is-directory-not-writable) | | **Arrays** | [assert_arrays_equal](#assert-arrays-equal) · [assert_array_contains](#assert-array-contains) · [assert_array_not_contains](#assert-array-not-contains) · [assert_array_length](#assert-array-length) | @@ -832,6 +832,33 @@ function test_failure() { ``` ::: +## assert_command_available +> `assert_command_available "command"` + +Reports an error if `command` is not available. + +Availability uses the same `command -v` check as +[`bashunit::is_command_available`](/globals#bashunit-is-command-available), so +external commands, shell builtins and shell functions are supported. The command +is only resolved; it is not executed. + +::: code-group +```bash [Example] +function test_dependencies_are_installed() { + assert_command_available bash + assert_command_available jq +} + +function test_shell_function_is_available() { + function project_build() { + make build + } + + assert_command_available project_build +} +``` +::: + ## assert_command_not_found > `assert_command_not_found` diff --git a/docs/public/bashunit-skill.md b/docs/public/bashunit-skill.md index 82dbcc8b..c0fce0ce 100644 --- a/docs/public/bashunit-skill.md +++ b/docs/public/bashunit-skill.md @@ -118,7 +118,7 @@ cleaned up automatically and are safe under `--parallel`. ## Assertions -`bashunit doc` prints the full catalogue (73 assertions) locally; `bashunit doc contains` +`bashunit doc` prints the full catalogue (75 assertions) locally; `bashunit doc contains` filters it. The same list is at https://bashunit.com/assertions. **Do not invent names** — a wrong name is a runtime error, not a failed assertion. diff --git a/src/assert/core.sh b/src/assert/core.sh index c03a287b..3f43f9e7 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -814,6 +814,29 @@ function assert_general_error() { bashunit::state::add_assertions_passed } +## +# Reports an error unless the command resolves through +# bashunit::is_command_available. Builtins and shell functions therefore follow +# the same availability semantics as the public helper. +# Arguments: $1 - command name +## +function assert_command_available() { + bashunit::assert::should_skip && return 0 + if [ "$#" -lt 1 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 1 "command" "$#" + return 2 + fi + + local command_name=$1 + + if ! bashunit::is_command_available "$command_name"; then + bashunit::assertion_failed "$command_name" "not found" "to be available but was " + return + fi + + bashunit::assertion_passed +} + function assert_command_not_found() { local actual_exit_code=${3-"$?"} # Capture $? before guard check local label_override="" diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index 3d150e4f..8ecaca46 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -319,6 +319,18 @@ Use assert_exec if you want to pass a command as a string and check its exit cod - assert_exit_code is the full version of this assertion where you can specify the expected exit code. +## assert_command_available +-------------- +> `assert_command_available "command"` + +Reports an error if `command` is not available. + +Availability uses the same `command -v` check as +`bashunit::is_command_available`(/globals#bashunit-is-command-available), so +external commands, shell builtins and shell functions are supported. The command +is only resolved; it is not executed. + + ## assert_command_not_found -------------- > `assert_command_not_found` diff --git a/tests/unit/assert/advanced_test.sh b/tests/unit/assert/advanced_test.sh index fc29dd07..3c79ea3d 100644 --- a/tests/unit/assert/advanced_test.sh +++ b/tests/unit/assert/advanced_test.sh @@ -55,6 +55,49 @@ function test_unsuccessful_assert_command_not_found() { "$(assert_command_not_found "$(fake_function)")" } +function test_successful_assert_command_available_for_external_command() { + assert_empty "$(assert_command_available bash)" +} + +function test_successful_assert_command_available_for_builtin() { + assert_empty "$(assert_command_available printf)" +} + +function test_successful_assert_command_available_for_shell_function() { + function available_shell_function() { + # shellcheck disable=SC2317 # Invoked indirectly by the availability lookup. + : + } + + assert_empty "$(assert_command_available available_shell_function)" +} + +function test_unsuccessful_assert_command_available_names_command() { + local command_name="bashunit_command_that_does_not_exist" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert command available names command" \ + "$command_name" "to be available but was " "not found")" \ + "$(assert_command_available "$command_name")" +} + +function test_assert_command_available_is_symmetric_with_assert_command_not_found() { + local command_name="printf" + local exit_code=0 + "$command_name" >/dev/null 2>&1 || exit_code=$? + + assert_assertion_passes assert_command_available "$command_name" + assert_assertion_fails assert_command_not_found "" "" "$exit_code" + + command_name="bashunit_command_that_does_not_exist" + exit_code=0 + "$command_name" >/dev/null 2>&1 || exit_code=$? + + assert_assertion_fails assert_command_available "$command_name" + assert_assertion_passes assert_command_not_found "" "" "$exit_code" +} + function test_successful_assert_exec() { # shellcheck disable=SC2317 function fake_command() { diff --git a/tests/unit/assert/arity_test.sh b/tests/unit/assert/arity_test.sh index 285f087c..6b312e66 100644 --- a/tests/unit/assert/arity_test.sh +++ b/tests/unit/assert/arity_test.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash -# @data_provider provide_core_comparison_assertions -function test_core_comparison_assertions_reject_missing_arguments() { +# @data_provider provide_core_assertions_requiring_arguments +function test_core_assertions_reject_missing_arguments() { local assertion=$1 local required=$2 local signature=$3 local output exit_code=0 - if [ "$required" -eq 3 ]; then + if [ "$required" -eq 1 ]; then + output=$("$assertion" 2>&1) || exit_code=$? + elif [ "$required" -eq 3 ]; then output=$("$assertion" "first" "second" 2>&1) || exit_code=$? else output=$("$assertion" "first" 2>&1) || exit_code=$? @@ -19,7 +21,7 @@ function test_core_comparison_assertions_reject_missing_arguments() { "$output" } -function provide_core_comparison_assertions() { +function provide_core_assertions_requiring_arguments() { 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" @@ -41,6 +43,7 @@ function provide_core_comparison_assertions() { 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" + bashunit::data_set assert_command_available 1 "command" } function test_empty_values_still_count_as_supplied_arguments() { From aecd3ead953c554129d4ead6e69d59dc016ae21e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 9 Aug 2026 21:54:12 +0200 Subject: [PATCH 2/4] fix(assert): report assert_command_available through fail_with bashunit::assertion_failed is the facade for user-defined custom assertions; it adds a stack frame on top of bashunit::assert::fail_with, whose label fallback compensates for exactly one. An assertion failing with no test_* frame on the stack (e.g. inside set_up) was therefore labelled "Bashunit::assertion failed" instead of "Assert command available" -- the same class of regression already pinned for bashunit::assert::label in bashunit_hook_failure_test.sh. Call bashunit::assert::fail_with / bashunit::state::add_assertions_passed directly like every other assertion in the file, and cover the facade frame in bashunit_hook_failure_test.sh. Also: - drop the trailing space in the failure condition, which doubled the space before the actual value - accept the conventional label override as $2 - replace the assert_command_not_found "symmetry" test, which only restated that a missing command exits 127, with a counter-movement test and a custom-label test - reword the docs link so `bashunit doc` renders it like the others Claude-Session: https://claude.ai/code/session_01VdeSjVXBGPWELdq5iDdczY --- docs/assertions.md | 4 ++-- src/assert/core.sh | 14 ++++++++--- .../acceptance/bashunit_hook_failure_test.sh | 21 ++++++++++++++++ ...it_should_display_all_assert_docs.snapshot | 4 ++-- tests/unit/assert/advanced_test.sh | 24 ++++++++----------- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/docs/assertions.md b/docs/assertions.md index ca68e457..19d0bba0 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -837,8 +837,8 @@ function test_failure() { Reports an error if `command` is not available. -Availability uses the same `command -v` check as -[`bashunit::is_command_available`](/globals#bashunit-is-command-available), so +Availability uses the same `command -v` check as the +[bashunit::is_command_available](/globals#bashunit-is-command-available) helper, so external commands, shell builtins and shell functions are supported. The command is only resolved; it is not executed. diff --git a/src/assert/core.sh b/src/assert/core.sh index 3f43f9e7..767635c4 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -818,7 +818,13 @@ function assert_general_error() { # Reports an error unless the command resolves through # bashunit::is_command_available. Builtins and shell functions therefore follow # the same availability semantics as the public helper. -# Arguments: $1 - command name +# +# Reports through bashunit::assert::fail_with, not the public +# bashunit::assertion_failed facade: the facade adds a stack frame, which would +# make the label fallback report the facade's name for an assertion failing +# outside a test_* frame. Pinned by tests/acceptance/bashunit_hook_failure_test.sh. +# +# Arguments: $1 - command name, $2 - label override (optional) ## function assert_command_available() { bashunit::assert::should_skip && return 0 @@ -828,13 +834,15 @@ function assert_command_available() { fi local command_name=$1 + local label_override="${2:-}" if ! bashunit::is_command_available "$command_name"; then - bashunit::assertion_failed "$command_name" "not found" "to be available but was " + bashunit::assert::fail_with "${label_override:-}" \ + "$command_name" "to be available but was" "not found" return fi - bashunit::assertion_passed + bashunit::state::add_assertions_passed } function assert_command_not_found() { diff --git a/tests/acceptance/bashunit_hook_failure_test.sh b/tests/acceptance/bashunit_hook_failure_test.sh index 2c4a10ce..524138dc 100644 --- a/tests/acceptance/bashunit_hook_failure_test.sh +++ b/tests/acceptance/bashunit_hook_failure_test.sh @@ -105,3 +105,24 @@ function test_array_assertion_failing_in_a_hook_is_labelled_with_its_own_name() assert_contains "Failed: Assert arrays equal" "$output" assert_not_contains "Bashunit::assert::label" "$output" } + +# Companion to the above for the other wrapper that adds a frame: the public +# `bashunit::assertion_failed` facade. Built-in assertions must call +# `bashunit::assert::fail_with` directly -- routing through the facade the way a +# user-defined custom assertion does makes the fallback report the facade's own +# name ("Bashunit::assertion failed") instead of the assertion's. +function test_assertion_failing_in_a_hook_is_not_labelled_with_the_facade_name() { + local fixture + fixture="$(_write_failing_hook_fixture \ + 'assert_command_available "bashunit_command_that_does_not_exist"' \ + "facade_label_fallback_test.sh")" + + local output + local exit_code=0 + output=$(./bashunit --no-parallel --detailed --no-color --skip-env-file \ + "$fixture" 2>&1) || exit_code=$? + + assert_general_error "" "" "$exit_code" + assert_contains "Failed: Assert command available" "$output" + assert_not_contains "Bashunit::assertion failed" "$output" +} diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index 8ecaca46..eaa54f5f 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -325,8 +325,8 @@ Use assert_exec if you want to pass a command as a string and check its exit cod Reports an error if `command` is not available. -Availability uses the same `command -v` check as -`bashunit::is_command_available`(/globals#bashunit-is-command-available), so +Availability uses the same `command -v` check as the +bashunit::is_command_available(/globals#bashunit-is-command-available) helper, so external commands, shell builtins and shell functions are supported. The command is only resolved; it is not executed. diff --git a/tests/unit/assert/advanced_test.sh b/tests/unit/assert/advanced_test.sh index 3c79ea3d..a20a52b7 100644 --- a/tests/unit/assert/advanced_test.sh +++ b/tests/unit/assert/advanced_test.sh @@ -78,24 +78,20 @@ function test_unsuccessful_assert_command_available_names_command() { assert_same \ "$(bashunit::console_results::print_failed_test \ "Unsuccessful assert command available names command" \ - "$command_name" "to be available but was " "not found")" \ + "$command_name" "to be available but was" "not found")" \ "$(assert_command_available "$command_name")" } -function test_assert_command_available_is_symmetric_with_assert_command_not_found() { - local command_name="printf" - local exit_code=0 - "$command_name" >/dev/null 2>&1 || exit_code=$? - - assert_assertion_passes assert_command_available "$command_name" - assert_assertion_fails assert_command_not_found "" "" "$exit_code" - - command_name="bashunit_command_that_does_not_exist" - exit_code=0 - "$command_name" >/dev/null 2>&1 || exit_code=$? +function test_unsuccessful_assert_command_available_with_custom_label() { + assert_same \ + "$(bashunit::console_results::print_failed_test "my custom label" \ + "bashunit_command_that_does_not_exist" "to be available but was" "not found")" \ + "$(assert_command_available "bashunit_command_that_does_not_exist" "my custom label")" +} - assert_assertion_fails assert_command_available "$command_name" - assert_assertion_passes assert_command_not_found "" "" "$exit_code" +function test_assert_command_available_moves_the_assertion_counters() { + assert_assertion_passes assert_command_available printf + assert_assertion_fails assert_command_available "bashunit_command_that_does_not_exist" } function test_successful_assert_exec() { From 718469afea489f39b0d97137d274163b91c06814 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 9 Aug 2026 21:56:03 +0200 Subject: [PATCH 3/4] docs(assert): list assert_command_available in the agent skill catalogue The skill file already bumped the assertion count but left the new name out of the "Exit codes" bullet, and that list is what an agent reads to avoid inventing assertion names. Claude-Session: https://claude.ai/code/session_01VdeSjVXBGPWELdq5iDdczY --- docs/public/bashunit-skill.md | 2 +- tests/unit/assert/advanced_test.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/public/bashunit-skill.md b/docs/public/bashunit-skill.md index c0fce0ce..7f08f7cc 100644 --- a/docs/public/bashunit-skill.md +++ b/docs/public/bashunit-skill.md @@ -127,7 +127,7 @@ filters it. The same list is at https://bashunit.com/assertions. **Do not invent `assert_string_starts_with`, `assert_string_ends_with`, `assert_empty`, `assert_not_empty` - Numbers: `assert_greater_than`, `assert_less_than`, `assert_within_delta` - Exit codes: `assert_successful_code`, `assert_general_error`, `assert_exit_code`, - `assert_command_not_found` + `assert_command_not_found`, `assert_command_available` - Files: `assert_file_exists`, `assert_file_contains`, `assert_is_file_empty`, `assert_directory_exists`, `assert_file_permissions` - Arrays: `assert_array_contains`, `assert_array_length`, `assert_arrays_equal` diff --git a/tests/unit/assert/advanced_test.sh b/tests/unit/assert/advanced_test.sh index a20a52b7..8aa66421 100644 --- a/tests/unit/assert/advanced_test.sh +++ b/tests/unit/assert/advanced_test.sh @@ -65,7 +65,7 @@ function test_successful_assert_command_available_for_builtin() { function test_successful_assert_command_available_for_shell_function() { function available_shell_function() { - # shellcheck disable=SC2317 # Invoked indirectly by the availability lookup. + # shellcheck disable=SC2317 # Never invoked: the assertion only resolves it. : } From 8b3c115330014020564574ef988f5d6e96087e41 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 9 Aug 2026 22:17:10 +0200 Subject: [PATCH 4/4] refactor(assert): tighten assert_command_available and its arity coverage - trim the doc block to the house Arguments form; the note about calling fail_with instead of the assertion_failed facade duplicated fail_with's own documented contract - name the local `command`, matching the documented signature and the doubles modules - drop the hook-failure acceptance test: it spends a full CLI run pinning the facade frame depth for an assertion that never routes through the facade, and the two existing guards already pin fail_with's depth generically - build the arity probe's argument list from the required count instead of branching on it, so a new arity needs no new branch - assert the inverse relationship with assert_command_not_found in both directions (issue acceptance criterion) --- src/assert/core.sh | 19 +++++++---------- .../acceptance/bashunit_hook_failure_test.sh | 21 ------------------- tests/unit/assert/advanced_test.sh | 12 +++++++++++ tests/unit/assert/arity_test.sh | 18 +++++++++------- 4 files changed, 30 insertions(+), 40 deletions(-) diff --git a/src/assert/core.sh b/src/assert/core.sh index 767635c4..e0954866 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -816,15 +816,10 @@ function assert_general_error() { ## # Reports an error unless the command resolves through -# bashunit::is_command_available. Builtins and shell functions therefore follow -# the same availability semantics as the public helper. -# -# Reports through bashunit::assert::fail_with, not the public -# bashunit::assertion_failed facade: the facade adds a stack frame, which would -# make the label fallback report the facade's name for an assertion failing -# outside a test_* frame. Pinned by tests/acceptance/bashunit_hook_failure_test.sh. -# -# Arguments: $1 - command name, $2 - label override (optional) +# bashunit::is_command_available, so builtins and shell functions count as +# available exactly as they do for that helper. The command is resolved, never +# executed. +# Arguments: $1 - command, $2 - label override (optional) ## function assert_command_available() { bashunit::assert::should_skip && return 0 @@ -833,12 +828,12 @@ function assert_command_available() { return 2 fi - local command_name=$1 + local command="$1" local label_override="${2:-}" - if ! bashunit::is_command_available "$command_name"; then + if ! bashunit::is_command_available "$command"; then bashunit::assert::fail_with "${label_override:-}" \ - "$command_name" "to be available but was" "not found" + "${command}" "to be available but was" "not found" return fi diff --git a/tests/acceptance/bashunit_hook_failure_test.sh b/tests/acceptance/bashunit_hook_failure_test.sh index 524138dc..2c4a10ce 100644 --- a/tests/acceptance/bashunit_hook_failure_test.sh +++ b/tests/acceptance/bashunit_hook_failure_test.sh @@ -105,24 +105,3 @@ function test_array_assertion_failing_in_a_hook_is_labelled_with_its_own_name() assert_contains "Failed: Assert arrays equal" "$output" assert_not_contains "Bashunit::assert::label" "$output" } - -# Companion to the above for the other wrapper that adds a frame: the public -# `bashunit::assertion_failed` facade. Built-in assertions must call -# `bashunit::assert::fail_with` directly -- routing through the facade the way a -# user-defined custom assertion does makes the fallback report the facade's own -# name ("Bashunit::assertion failed") instead of the assertion's. -function test_assertion_failing_in_a_hook_is_not_labelled_with_the_facade_name() { - local fixture - fixture="$(_write_failing_hook_fixture \ - 'assert_command_available "bashunit_command_that_does_not_exist"' \ - "facade_label_fallback_test.sh")" - - local output - local exit_code=0 - output=$(./bashunit --no-parallel --detailed --no-color --skip-env-file \ - "$fixture" 2>&1) || exit_code=$? - - assert_general_error "" "" "$exit_code" - assert_contains "Failed: Assert command available" "$output" - assert_not_contains "Bashunit::assertion failed" "$output" -} diff --git a/tests/unit/assert/advanced_test.sh b/tests/unit/assert/advanced_test.sh index 8aa66421..0660f9fb 100644 --- a/tests/unit/assert/advanced_test.sh +++ b/tests/unit/assert/advanced_test.sh @@ -94,6 +94,18 @@ function test_assert_command_available_moves_the_assertion_counters() { assert_assertion_fails assert_command_available "bashunit_command_that_does_not_exist" } +# The two never agree about the same command. Both run inside $() so their +# failures stay in the subshell instead of moving this test's counters. +function test_assert_command_available_is_the_inverse_of_assert_command_not_found() { + # Missing: not_found passes on the 127, available fails. + assert_empty "$(assert_command_not_found "$(bashunit_command_that_does_not_exist 2>/dev/null)")" + assert_not_empty "$(assert_command_available "bashunit_command_that_does_not_exist")" + + # Present: available passes, not_found fails on the 0. + assert_empty "$(assert_command_available printf)" + assert_not_empty "$(assert_command_not_found "$(printf '')")" +} + function test_successful_assert_exec() { # shellcheck disable=SC2317 function fake_command() { diff --git a/tests/unit/assert/arity_test.sh b/tests/unit/assert/arity_test.sh index 6b312e66..21aa71b1 100644 --- a/tests/unit/assert/arity_test.sh +++ b/tests/unit/assert/arity_test.sh @@ -7,13 +7,17 @@ function test_core_assertions_reject_missing_arguments() { local signature=$3 local output exit_code=0 - if [ "$required" -eq 1 ]; then - output=$("$assertion" 2>&1) || exit_code=$? - elif [ "$required" -eq 3 ]; then - output=$("$assertion" "first" "second" 2>&1) || exit_code=$? - else - output=$("$assertion" "first" 2>&1) || exit_code=$? - fi + # One argument short of the signature, whatever the arity. The `+` guard keeps + # an empty list expandable under `set -u` on Bash 3.x (a one-argument + # assertion is called with nothing at all). + local supplied=1 + local -a args=() + while [ "$supplied" -lt "$required" ]; do + args[${#args[@]}]="arg${supplied}" + supplied=$((supplied + 1)) + done + + output=$("$assertion" "${args[@]+"${args[@]}"}" 2>&1) || exit_code=$? assert_same 2 "$exit_code" assert_same \