Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 29 additions & 14 deletions src/assert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}"
Expand Down Expand Up @@ -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"*) ;;
Expand Down Expand Up @@ -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"*)
Expand All @@ -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
Expand All @@ -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 ] ||
Expand Down Expand Up @@ -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"*) ;;
Expand Down Expand Up @@ -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") ;;
Expand All @@ -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")
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/assert_string_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
Expand Down
Loading