diff --git a/CHANGELOG.md b/CHANGELOG.md index 983a520f..bdbe7a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Changed +- Core string assertions (`assert_contains`/`assert_not_contains`, `assert_matches`/`assert_not_matches`, `assert_string_starts_with`/`assert_string_ends_with` and their negations) no longer fork a subshell per call to join their arguments; a fork-free join with identical behaviour replaces it (#844) + ## [0.42.0](https://github.com/TypedDevs/bashunit/compare/0.41.0...0.42.0) - 2026-07-20 ### Fixed diff --git a/src/assert.sh b/src/assert.sh index a7d309cf..6436fb5b 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -28,6 +28,21 @@ function bashunit::assert::label_to_slot() { _BASHUNIT_ASSERT_LABEL_OUT=$_BASHUNIT_HELPER_NORMALIZED_OUT } +_BASHUNIT_ASSERT_JOINED_OUT="" + +# Join positional args into _BASHUNIT_ASSERT_JOINED_OUT with no fork. +# Mirrors $(printf '%s\n' "$@"): joins with newlines and strips trailing +# newlines, so callers that match on the result behave exactly as the previous +# command-substitution did. +function bashunit::assert::join_to_slot() { + local IFS=$'\n' + local joined="$*" + while [ "$joined" != "${joined%$'\n'}" ]; do + joined="${joined%$'\n'}" + done + _BASHUNIT_ASSERT_JOINED_OUT=$joined +} + # Resolve assertion label: use custom label if provided, otherwise derive from test function name function bashunit::assert::label() { bashunit::assert::label_to_slot "${1:-}" @@ -273,8 +288,8 @@ function assert_contains() { local -a actual_arr actual_arr=("${@:2}") local label_override="" - local actual - actual=$(printf '%s\n' "${actual_arr[@]}") + bashunit::assert::join_to_slot "${actual_arr[@]}" + local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected"*) ;; @@ -326,8 +341,8 @@ function assert_not_contains() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - local actual - actual=$(printf '%s\n' "${actual_arr[@]}") + bashunit::assert::join_to_slot "${actual_arr[@]}" + local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected"*) @@ -349,8 +364,8 @@ function assert_matches() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - local actual - actual=$(printf '%s\n' "${actual_arr[@]}") + bashunit::assert::join_to_slot "${actual_arr[@]}" + local actual=$_BASHUNIT_ASSERT_JOINED_OUT if [ "$(printf '%s' "$actual" | "$GREP" -cE "$expected" || true)" -eq 0 ]; then # Retry with newlines collapsed for cross-line patterns @@ -375,8 +390,8 @@ function assert_not_matches() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - local actual - actual=$(printf '%s\n' "${actual_arr[@]}") + bashunit::assert::join_to_slot "${actual_arr[@]}" + local actual=$_BASHUNIT_ASSERT_JOINED_OUT # Check both line-by-line and with newlines collapsed for cross-line patterns if [ "$(printf '%s' "$actual" | "$GREP" -cE "$expected" || true)" -gt 0 ] || @@ -652,8 +667,8 @@ function assert_string_starts_with() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - local actual - actual=$(printf '%s\n' "${actual_arr[@]}") + bashunit::assert::join_to_slot "${actual_arr[@]}" + local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in "$expected"*) ;; @@ -697,8 +712,8 @@ function assert_string_ends_with() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - local actual - actual=$(printf '%s\n' "${actual_arr[@]}") + bashunit::assert::join_to_slot "${actual_arr[@]}" + local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected") ;; @@ -722,8 +737,8 @@ function assert_string_not_ends_with() { local expected="$1" local -a actual_arr actual_arr=("${@:2}") - local actual - actual=$(printf '%s\n' "${actual_arr[@]}") + bashunit::assert::join_to_slot "${actual_arr[@]}" + local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in *"$expected") diff --git a/tests/unit/assert_string_test.sh b/tests/unit/assert_string_test.sh index 3bd2720f..6b110cf1 100644 --- a/tests/unit/assert_string_test.sh +++ b/tests/unit/assert_string_test.sh @@ -142,6 +142,28 @@ function test_unsuccessful_assert_string_not_ends_with() { "$(assert_string_not_ends_with "bar" "foobar")" } +# Characterization: the haystack join must strip trailing newlines (the behavior +# $(printf '%s\n' ...) gave), preserve internal newlines, and join multiple args +# with newlines. These pin the contract the fork-free join must keep. +function test_assert_contains_ignores_trailing_newlines_in_haystack() { + assert_empty "$(assert_contains "bar" "$(printf 'foobar')"$'\n')" + assert_empty "$(assert_contains "bar" "foobar"$'\n\n\n')" +} + +function test_assert_string_ends_with_ignores_trailing_newlines_in_haystack() { + assert_empty "$(assert_string_ends_with "bar" "foobar"$'\n')" + assert_empty "$(assert_string_ends_with "bar" "foobar"$'\n\n\n')" +} + +function test_assert_contains_preserves_internal_newlines_in_haystack() { + assert_empty "$(assert_contains "b" "a"$'\n'"b"$'\n'"c")" +} + +function test_assert_contains_joins_multiple_haystack_args_with_newlines() { + assert_empty "$(assert_contains "b" "a" "b" "c")" + assert_empty "$(assert_string_ends_with "c" "a" "b" "c")" +} + function test_assert_string_start_end_with_special_chars() { assert_empty "$(assert_string_starts_with "foo." "foo.bar")" assert_empty "$(assert_string_ends_with ".bar" "foo.bar")"