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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

### Changed
- Lower minimum Bash version requirement from 3.2 to 3.0
- Improved parallel test execution performance (30-40% faster on large test suites)
- Test functions now run in parallel within each file when using `--parallel` flag
- Better load balancing through internal test file reorganization
- Optimized result aggregation eliminates subprocess overhead

### Added
- Add Claude Code configuration with custom skills, agents, and rules
Expand All @@ -21,6 +25,10 @@
- Shows only the final test summary
- Useful for CI/CD pipelines or log-restricted environments
- Can also be set via `BASHUNIT_NO_PROGRESS=true` environment variable
- Support for `# bashunit: no-parallel-tests` directive in test files
- Allows test files to opt out of test-level parallelism
- Useful for tests with shared state or race conditions
- Add as the second line in test files (after shebang)

### Fixed
- Data providers now work without the `function` keyword on test functions (Issue #586)
Expand All @@ -29,6 +37,10 @@
- Fixes regex in `bashunit::helper::get_provider_data()` to make the `function` keyword optional
- Self-test `tests/acceptance/install_test.sh` now passes when no network tools are available (Issue #582)
- Tests skip gracefully with `BASHUNIT_NO_NETWORK=true` or in sandboxed environments
- Parallel test execution now works correctly in strict mode environments (bash -e -o pipefail)
- Fixed arithmetic operations in result aggregation to prevent exit code 1 when values are zero
- Fixed spinner cleanup to handle already-terminated processes gracefully
- Ensures proper exit codes in CI environments like GitHub Actions Windows runners

## [0.32.0](https://github.com/TypedDevs/bashunit/compare/0.31.0...0.32.0) - 2026-01-12

Expand Down
20 changes: 20 additions & 0 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,32 @@ The line number syntax finds the test function that contains the specified line.

Run tests in parallel or sequentially. Sequential is the default.

In parallel mode, both test files and individual test functions run concurrently
for maximum performance.

::: warning
Parallel mode is supported on **macOS**, **Ubuntu**, and **Windows**. On other
systems (like Alpine Linux) the option is automatically disabled due to
inconsistent results.
:::

::: tip Opt-out of test-level parallelism
If a test file has shared state or race conditions, you can disable test-level
parallelism by adding this directive as the second line:

```bash
#!/usr/bin/env bash
# bashunit: no-parallel-tests

function test_with_shared_state() {
# This test will not run in parallel with others in this file
}
```

The file will still run in parallel with other files, but tests within it will
run sequentially.
:::

### Output Style

> `bashunit test -s|--simple`
Expand Down
26 changes: 22 additions & 4 deletions release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,28 @@ function release::sandbox::create() {
release::log_info "Creating sandbox at: $SANDBOX_DIR"

# Copy repo content excluding .git, .release-state, node_modules
# Using cp + rm for portability (rsync not available on all systems)
cp -r . "$SANDBOX_DIR/"
rm -rf "$SANDBOX_DIR/.git" "$SANDBOX_DIR/.release-state" "$SANDBOX_DIR/node_modules"
release::log_verbose "Copied project files to sandbox"
# Try tar pipe first (faster), fallback to cp + rm for portability
# Disable errexit temporarily to allow tar fallback in strict mode
local tar_status=0
set +e
tar --exclude='.git' \
--exclude='.release-state' \
--exclude='node_modules' \
--exclude='.tasks' \
--exclude='tmp' \
-cf - . 2>/dev/null | tar -xf - -C "$SANDBOX_DIR" 2>/dev/null
tar_status=$?
set -e

if [ "$tar_status" -eq 0 ]; then
release::log_verbose "Copied project files to sandbox (tar)"
else
# Fallback: traditional cp + rm for maximum portability
cp -r . "$SANDBOX_DIR/"
rm -rf "$SANDBOX_DIR/.git" "$SANDBOX_DIR/.release-state" \
"$SANDBOX_DIR/node_modules" "$SANDBOX_DIR/.tasks" "$SANDBOX_DIR/tmp"
release::log_verbose "Copied project files to sandbox (cp)"
fi
}

function release::sandbox::setup_git() {
Expand Down
3 changes: 2 additions & 1 deletion src/parallel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ function bashunit::parallel::aggregate_test_results() {
local result_file=""
for result_file in "${result_files[@]+"${result_files[@]}"}"; do
local result_line
result_line=$(tail -n 1 <"$result_file")
result_line=$(<"$result_file")
result_line="${result_line##*$'\n'}"

local failed="${result_line##*##ASSERTIONS_FAILED=}"
failed="${failed%%##*}"
Expand Down
30 changes: 25 additions & 5 deletions src/runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ function bashunit::runner::load_test_files() {
local spinner_pid=$!
bashunit::parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE"
# Kill the spinner once the aggregation finishes
disown "$spinner_pid" && kill "$spinner_pid" &>/dev/null
disown "$spinner_pid" 2>/dev/null || true
kill "$spinner_pid" 2>/dev/null || true
printf "\r \r" # Clear the spinner output
local script_id
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
Expand Down Expand Up @@ -342,6 +343,12 @@ function bashunit::runner::call_test_functions() {

bashunit::helper::check_duplicate_functions "$script" || true

# Check if test file opts out of test-level parallelism
local allow_test_parallel=true
if grep -q "^# bashunit: no-parallel-tests" "$script" 2>/dev/null; then
allow_test_parallel=false
fi

local -a provider_data=()
local provider_data_count=0
local -a parsed_data=()
Expand All @@ -362,8 +369,12 @@ function bashunit::runner::call_test_functions() {
done <<<"$(bashunit::helper::get_provider_data "$fn_name" "$script")"

# No data provider found
if [[ "$provider_data_count" -eq 0 ]]; then
bashunit::runner::run_test "$script" "$fn_name"
if [ "$provider_data_count" -eq 0 ]; then
if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then
bashunit::runner::run_test "$script" "$fn_name" &
else
bashunit::runner::run_test "$script" "$fn_name"
fi
unset -v fn_name
continue
fi
Expand All @@ -375,14 +386,23 @@ function bashunit::runner::call_test_functions() {
parsed_data_count=0
local line
while IFS= read -r line; do
[[ -z "$line" ]] && continue
[ -z "$line" ] && continue
parsed_data[parsed_data_count]="$(bashunit::helper::decode_base64 "${line}")"
parsed_data_count=$((parsed_data_count + 1))
done <<<"$(bashunit::runner::parse_data_provider_args "$data")"
bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"}
if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then
bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"} &
else
bashunit::runner::run_test "$script" "$fn_name" ${parsed_data+"${parsed_data[@]}"}
fi
done
unset -v fn_name
done

# Wait for all parallel tests within this file to complete
if bashunit::parallel::is_enabled && [ "$allow_test_parallel" = true ]; then
wait
fi
}

function bashunit::runner::call_bench_functions() {
Expand Down
60 changes: 60 additions & 0 deletions tests/acceptance/bashunit_assert_basic_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail

function set_up() {
export BASHUNIT_SIMPLE_OUTPUT=false
}

# Test basic assert subcommand functionality
function test_bashunit_assert_subcommand_equals() {
./bashunit assert equals "foo" "foo"
assert_successful_code
}

function test_bashunit_assert_subcommand_same() {
./bashunit assert same "1" "1"
assert_successful_code
}

function test_bashunit_assert_subcommand_contains() {
./bashunit assert contains "world" "hello world"
assert_successful_code
}

function test_bashunit_assert_subcommand_without_prefix() {
./bashunit assert equals "bar" "bar"
assert_successful_code
}

# Test help functionality
function test_bashunit_assert_subcommand_help_short() {
local output
output=$(./bashunit assert -h 2>&1)

assert_contains "Usage: bashunit assert" "$output"
assert_contains "Run standalone assertion" "$output"
assert_successful_code "$(./bashunit assert -h)"
}

function test_bashunit_assert_subcommand_help_long() {
local output
output=$(./bashunit assert --help 2>&1)

assert_contains "Usage: bashunit assert" "$output"
assert_contains "Single assertion:" "$output"
assert_successful_code "$(./bashunit assert --help)"
}

# Test assert subcommand is in main help
function test_bashunit_main_help_includes_assert() {
local output
output=$(./bashunit --help 2>&1)

assert_contains "assert <fn> <args>" "$output"
}

function test_multi_assert_help_shows_multi_syntax() {
local output
output=$(./bashunit assert --help 2>&1)
assert_contains "Multiple assertions on command output" "$output"
}
50 changes: 50 additions & 0 deletions tests/acceptance/bashunit_assert_errors_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail

function set_up() {
export BASHUNIT_SIMPLE_OUTPUT=false
}

# Test error cases
function test_bashunit_assert_subcommand_no_function() {
local output
local exit_code
output=$(./bashunit assert 2>&1) && exit_code=$? || exit_code=$?

assert_contains "Error: Assert function name or command is required" "$output"
assert_general_error "" "" "$exit_code"
}

function test_bashunit_assert_subcommand_non_existing_function() {
local exit_code
./bashunit assert non_existing_function 2>&1 && exit_code=$? || exit_code=$?
assert_command_not_found "" "" "$exit_code"
}

function test_bashunit_assert_subcommand_failure() {
local exit_code
./bashunit --no-parallel assert equals "foo" "bar" 2>&1 && exit_code=$? || exit_code=$?
assert_general_error "" "" "$exit_code"
}

# Test backward compatibility with --assert option
function test_bashunit_old_assert_option_still_works() {
local output
output=$(./bashunit -a equals "foo" "foo" 2>&1)
assert_successful_code "$output"
}

function test_bashunit_old_assert_option_long_form() {
local output
output=$(./bashunit --assert equals "foo" "foo" 2>&1)
assert_successful_code "$output"
}

# Test deprecation notice in help
function test_bashunit_test_help_shows_deprecation() {
local output
output=$(./bashunit test --help 2>&1)

assert_contains "deprecated" "$output"
assert_contains "bashunit assert" "$output"
}
42 changes: 42 additions & 0 deletions tests/acceptance/bashunit_assert_multi_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail

function set_up() {
export BASHUNIT_SIMPLE_OUTPUT=false
}

# Test multi-assertion mode
function test_multi_assert_exit_code_and_contains() {
./bashunit assert "echo 'some error' && exit 1" exit_code "1" contains "some error" 2>&1
assert_successful_code
}

function test_multi_assert_exit_code_zero_and_output() {
./bashunit assert "echo 'success message'" exit_code "0" contains "success" 2>&1
assert_successful_code
}

function test_multi_assert_multiple_output_assertions() {
./bashunit assert "echo 'hello world'" exit_code "0" contains "hello" contains "world" 2>&1
assert_successful_code
}

function test_multi_assert_fails_on_exit_code_mismatch() {
local exit_code
./bashunit assert "echo 'output' && exit 1" exit_code "0" 2>&1 && exit_code=$? || exit_code=$?
assert_general_error "" "" "$exit_code"
}

function test_multi_assert_fails_on_contains_mismatch() {
local exit_code
./bashunit assert "echo 'actual output'" exit_code "0" contains "expected" 2>&1 && exit_code=$? || exit_code=$?
assert_general_error "" "" "$exit_code"
}

function test_multi_assert_missing_assertion_arg() {
local exit_code
local output
output=$(./bashunit assert "echo test" exit_code 2>&1) && exit_code=$? || exit_code=$?
assert_contains "Missing argument for assertion" "$output"
assert_general_error "" "" "$exit_code"
}
Loading
Loading