Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e374f8c
feat: lower minimum bash version requirement from 3.2 to 3.0
Chemaclass Feb 3, 2026
5100702
fix: complete Bash 3.0 compatibility fixes
Chemaclass Feb 4, 2026
3a10363
fix: Bash 3.0 compatibility for assert_contains_ignore_case
Chemaclass Feb 4, 2026
c2fbe67
ci: run Bash 3.0 tests in parallel jobs
Chemaclass Feb 4, 2026
01dba04
fix: resolve shellcheck and lint errors
Chemaclass Feb 4, 2026
05ffbe1
ci: remove read-only flag from Docker volume mount
Chemaclass Feb 4, 2026
032107b
ci: add procps package for ps command in Docker
Chemaclass Feb 4, 2026
bfd3d97
ci: trigger fresh build
Chemaclass Feb 4, 2026
46f94b6
test: skip mock/spy external script tests on Bash 3.0
Chemaclass Feb 4, 2026
9235356
fix: use local -a for safe Bash 3.0 array init, fix variable scoping
Chemaclass Feb 4, 2026
af54c95
Merge branch 'main' into feat/support-bash-3.0
Chemaclass Feb 9, 2026
e274b8a
Merge branch 'main' into feat/support-bash-3.0
Chemaclass Feb 10, 2026
313c540
fix: improve Bash 3.0 regex compatibility and assignment quoting
Chemaclass Feb 10, 2026
54a1ff1
fix: prevent variable leakage in loops across all source files
Chemaclass Feb 10, 2026
68941fc
fix: add local declarations for all remaining loop variables
Chemaclass Feb 10, 2026
d7cd950
fix: initialize all loop variables to prevent unbound variable errors…
Chemaclass Feb 10, 2026
d68762c
fix: make array iteration safe for strict mode (set -u)
Chemaclass Feb 10, 2026
de43885
fix: remove redundant array size check in benchmark print function
Chemaclass Feb 10, 2026
a0e86ea
fix(runner): expand test_file at trap definition time to prevent unbo…
Chemaclass Feb 10, 2026
9c8f621
fix(compat): harden regex matching, array expansion, and parameter subs
Chemaclass Feb 10, 2026
ec935c2
revert: remove unnecessary loop variable initializations from d7cd950
Chemaclass Feb 10, 2026
421fbee
fix(coverage): initialize lineno counter to prevent strict mode error
Chemaclass Feb 10, 2026
e9be71b
fix(compat): address PR feedback for Bash 3.0 compatibility
Chemaclass Feb 11, 2026
834bd32
fix(compat): convert all literal regex patterns to variables
Chemaclass Feb 11, 2026
ea2970a
fix(compat): move regex pattern outside loop in doc.sh
Chemaclass Feb 11, 2026
bc993e1
fix(compat): use glob pattern instead of regex for code fence
Chemaclass Feb 11, 2026
81d47c4
Update src/reports.sh
Chemaclass Feb 11, 2026
2915045
Update src/runner.sh
Chemaclass Feb 11, 2026
a8be738
Update src/reports.sh
Chemaclass Feb 11, 2026
4cc506e
Update src/helpers.sh
Chemaclass Feb 11, 2026
8758009
Update src/main.sh
Chemaclass Feb 11, 2026
ec1f282
Update src/helpers.sh
Chemaclass Feb 11, 2026
47ced2c
Update src/learn.sh
Chemaclass Feb 11, 2026
90ba80a
Update src/main.sh
Chemaclass Feb 11, 2026
1280a79
fix(compat): store literal regex patterns in variables for Bash 3.0
Chemaclass Feb 11, 2026
172026f
fix(compat): simplify array init and add IFS guards
Chemaclass Feb 11, 2026
eda74cc
style: fix indentation from 8 spaces to 2 in acceptance tests and con…
Chemaclass Feb 11, 2026
197f4b2
refactor(compat): inline regex matching and remove bashunit::regex_ma…
Chemaclass Feb 11, 2026
a4e0ea6
fix(compat): add IFS guards to assert functions and fix array expansi…
Chemaclass Feb 12, 2026
320942c
fix(style): revert formatting changes to bin/ files
Chemaclass Feb 12, 2026
0944c13
docs: update Bash version references from 3.2+ to 3.0+
Chemaclass Feb 12, 2026
6dbb3e4
fix(style): bashunit
Chemaclass Feb 12, 2026
2c62d75
fix(compat): guard empty array expansion in parse_result_parallel
Chemaclass Feb 12, 2026
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
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Contributions are licensed under the [MIT License](https://github.com/TypedDevs/

### Prerequisites

- Bash 3.2+
- Bash 3.0+
- Git
- Make
- [ShellCheck](https://github.com/koalaman/shellcheck#installing)
Expand Down
6 changes: 3 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ An open-source **library** providing a fast, portable Bash testing framework: **
* Minimal overhead, plain Bash test files.
* Rich **assertions**, **test doubles (mock/spy)**, **data providers**, **snapshots**, **skip/todo**, **globals utilities**, **custom assertions**, **benchmarks**, and **standalone** runs.

**Compatibility**: Bash 3.2+ (macOS, Linux, WSL). No external dependencies beyond standard Unix tools.
**Compatibility**: Bash 3.0+ (macOS, Linux, WSL). No external dependencies beyond standard Unix tools.

---

Expand Down Expand Up @@ -284,7 +284,7 @@ We practice two nested feedback loops to deliver behavior safely and quickly.

### Compatibility & Portability
```bash
# ✅ GOOD - Works on Bash 3.2+
# ✅ GOOD - Works on Bash 3.0+
[[ -n "${var:-}" ]] && echo "set"
array=("item1" "item2")

Expand Down Expand Up @@ -1000,7 +1000,7 @@ Use this template for internal changes, fixes, refactors, documentation.
- **All tests pass** (`./bashunit tests/`)
- **Shellcheck passes** with existing exceptions (`shellcheck -x $(find . -name "*.sh")`)
- **Code formatted** (`shfmt -w .`)
- **Bash 3.2+ compatible** (no `declare -A`, no `readarray`, no `${var^^}`)
- **Bash 3.0+ compatible** (no `declare -A`, no `readarray`, no `${var^^)}`, no `printf -v`)
- **Follows established module namespacing** patterns

### ✅ Testing (following observed patterns)
Expand Down
101 changes: 101 additions & 0 deletions .github/workflows/tests-bash-3.0.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Bash 3.0 Compatibility

on:
pull_request:
push:
branches:
- main

jobs:
build-image:
name: "Build Bash 3.0 Image"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Build Bash 3.0 Docker image
run: |
docker build -t bashunit-bash3 -f - . <<'EOF'
FROM debian:bullseye-slim

RUN apt-get update && apt-get install -y \
build-essential \
curl \
ca-certificates \
bison \
git \
procps \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /tmp
RUN curl -LO https://ftp.gnu.org/gnu/bash/bash-3.0.tar.gz \
&& tar xzf bash-3.0.tar.gz \
&& cd bash-3.0 \
&& curl -fsSL -o support/config.guess 'https://raw.githubusercontent.com/gcc-mirror/gcc/master/config.guess' \
&& curl -fsSL -o support/config.sub 'https://raw.githubusercontent.com/gcc-mirror/gcc/master/config.sub' \
&& chmod +x support/config.guess support/config.sub \
&& ./configure --prefix=/opt/bash-3.0 \
&& make \
&& make install \
&& rm -rf /tmp/bash-3.0*

WORKDIR /bashunit
CMD ["/opt/bash-3.0/bin/bash", "--version"]
EOF

- name: Save Docker image
run: docker save bashunit-bash3 -o /tmp/bashunit-bash3.tar

- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: bashunit-bash3-image
path: /tmp/bashunit-bash3.tar
retention-days: 1

test:
name: "Bash 3.0 - ${{ matrix.name }}"
runs-on: ubuntu-latest
needs: build-image
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- name: "Sequential"
flags: ""
- name: "Parallel"
flags: "--parallel"
- name: "Simple"
flags: "--simple"
- name: "Simple Parallel"
flags: "--simple --parallel"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: bashunit-bash3-image
path: /tmp

- name: Load Docker image
run: docker load --input /tmp/bashunit-bash3.tar

- name: Verify Bash 3.0 version
run: docker run --rm bashunit-bash3 /opt/bash-3.0/bin/bash --version

- name: Run tests with Bash 3.0 (${{ matrix.name }})
run: |
docker run --rm \
-v "$(pwd)":/bashunit \
-w /bashunit \
bashunit-bash3 \
/opt/bash-3.0/bin/bash ./bashunit ${{ matrix.flags }} tests/
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Examples must mirror **real** patterns from `./tests/**` exactly:

## Path-Scoped Guidance

- `./src/**`: small, portable functions, namespaced; maintain Bash 3.2+ compatibility
- `./src/**`: small, portable functions, namespaced; maintain Bash 3.0+ compatibility
- `./tests/**`: behavior-focused tests using official assertions/doubles; avoid networks/unverified tools
- `./.tasks/**`: one file per change (`YYYY-MM-DD-slug.md`); keep AC, test inventory, current red bar, and timestamped Logbook updated
- `./adrs/**`: read first; when adding, use template and match existing ADR style
Expand Down
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
- Lower minimum Bash version requirement from 3.2 to 3.0

### Added
- Display test output (stdout/stderr) on failure for runtime errors
- Shows captured output in an "Output:" section when tests fail with runtime errors
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ You can find the complete documentation for **bashunit** online, including insta

## Requirements

bashunit requires **Bash 3.2** or newer.
bashunit requires **Bash 3.0** or newer.

## Contribute

Expand Down
8 changes: 4 additions & 4 deletions bashunit
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail

declare -r BASHUNIT_MIN_BASH_VERSION="3.2"
declare -r BASHUNIT_MIN_BASH_VERSION="3.0"

function _check_bash_version() {
local current_version
Expand All @@ -16,10 +16,10 @@ function _check_bash_version() {
current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)"
fi

local major minor
IFS=. read -r major minor _ <<< "$current_version"
local major
IFS=. read -r major _ <<< "$current_version"

if (( major < 3 )) || { (( major == 3 )) && (( minor < 2 )); }; then
if (( major < 3 )); then
printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2
exit 1
fi
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Here, we provide different options that you can use to install **bashunit** in y

## Requirements

bashunit requires **Bash 3.2** or newer.
bashunit requires **Bash 3.0** or newer.

## install.sh

Expand Down
39 changes: 21 additions & 18 deletions src/assert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ function assert_contains() {
bashunit::assert::should_skip && return 0

local expected="$1"
local actual_arr=("${@:2}")
local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}")
Comment thread
Chemaclass marked this conversation as resolved.
Outdated
local actual
actual=$(printf '%s\n' "${actual_arr[@]}")

Expand All @@ -242,28 +242,31 @@ function assert_contains_ignore_case() {
local expected="$1"
local actual="$2"

shopt -s nocasematch
# Bash 3.0 compatible: use tr for case-insensitive comparison
# (shopt nocasematch was introduced in Bash 3.1)
local expected_lower
local actual_lower
expected_lower=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')
actual_lower=$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')

if ! [[ $actual =~ $expected ]]; then
if [[ "$actual_lower" != *"$expected_lower"* ]]; then
local test_fn
test_fn="$(bashunit::helper::find_test_function_name)"
local label
label="$(bashunit::helper::normalize_test_function_name "$test_fn")"
bashunit::assert::mark_failed
bashunit::console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}"
shopt -u nocasematch
return
fi

shopt -u nocasematch
bashunit::state::add_assertions_passed
}

function assert_not_contains() {
bashunit::assert::should_skip && return 0

local expected="$1"
local actual_arr=("${@:2}")
local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}")
local actual
actual=$(printf '%s\n' "${actual_arr[@]}")

Expand All @@ -284,7 +287,7 @@ function assert_matches() {
bashunit::assert::should_skip && return 0

local expected="$1"
local actual_arr=("${@:2}")
local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}")
local actual
actual=$(printf '%s\n' "${actual_arr[@]}")

Expand All @@ -305,7 +308,7 @@ function assert_not_matches() {
bashunit::assert::should_skip && return 0

local expected="$1"
local actual_arr=("${@:2}")
local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}")
local actual
actual=$(printf '%s\n' "${actual_arr[@]}")

Expand Down Expand Up @@ -379,16 +382,16 @@ function assert_exec() {
fi

if $check_stdout; then
expected_desc+=$'\n'"stdout: $expected_stdout"
actual_desc+=$'\n'"stdout: $stdout"
expected_desc="$expected_desc"$'\n'"stdout: $expected_stdout"
actual_desc="$actual_desc"$'\n'"stdout: $stdout"
if [[ "$stdout" != "$expected_stdout" ]]; then
failed=1
fi
fi

if $check_stderr; then
expected_desc+=$'\n'"stderr: $expected_stderr"
actual_desc+=$'\n'"stderr: $stderr"
expected_desc="$expected_desc"$'\n'"stderr: $expected_stderr"
actual_desc="$actual_desc"$'\n'"stderr: $stderr"
if [[ "$stderr" != "$expected_stderr" ]]; then
failed=1
fi
Expand Down Expand Up @@ -507,7 +510,7 @@ function assert_string_starts_with() {
bashunit::assert::should_skip && return 0

local expected="$1"
local actual_arr=("${@:2}")
local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}")
local actual
actual=$(printf '%s\n' "${actual_arr[@]}")

Expand Down Expand Up @@ -547,7 +550,7 @@ function assert_string_ends_with() {
bashunit::assert::should_skip && return 0

local expected="$1"
local actual_arr=("${@:2}")
local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}")
local actual
actual=$(printf '%s\n' "${actual_arr[@]}")

Expand All @@ -568,7 +571,7 @@ function assert_string_not_ends_with() {
bashunit::assert::should_skip && return 0

local expected="$1"
local actual_arr=("${@:2}")
local actual_arr; [[ $# -gt 1 ]] && actual_arr=("${@:2}")
local actual
actual=$(printf '%s\n' "${actual_arr[@]}")

Expand Down Expand Up @@ -665,9 +668,9 @@ function assert_line_count() {
bashunit::assert::should_skip && return 0

local expected="$1"
local input_arr=("${@:2}")
local -a input_arr=(); [[ $# -gt 1 ]] && input_arr=("${@:2}")
local input_str
input_str=$(printf '%s\n' "${input_arr[@]}")
input_str=$(printf '%s\n' ${input_arr+"${input_arr[@]}"})
Comment thread
Chemaclass marked this conversation as resolved.

if [ -z "$input_str" ]; then
local actual=0
Expand All @@ -676,7 +679,7 @@ function assert_line_count() {
actual=$(echo "$input_str" | wc -l | tr -d '[:blank:]')
local additional_new_lines
additional_new_lines=$(grep -o '\\n' <<< "$input_str" | wc -l | tr -d '[:blank:]')
((actual+=additional_new_lines))
actual=$((actual + additional_new_lines))
fi

if [[ "$expected" != "$actual" ]]; then
Expand Down
8 changes: 4 additions & 4 deletions src/assert_arrays.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ function assert_array_contains() {
label="$(bashunit::helper::normalize_test_function_name "$test_fn")"
shift

local actual=("${@}")
local -a actual=(); [[ $# -gt 0 ]] && actual=("$@")

if ! [[ "${actual[*]}" == *"$expected"* ]]; then
if ! [[ "${actual[*]:-}" == *"$expected"* ]]; then
bashunit::assert::mark_failed
bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to contain" "${expected}"
return
Expand All @@ -30,9 +30,9 @@ function assert_array_not_contains() {
local label
label="$(bashunit::helper::normalize_test_function_name "$test_fn")"
shift
local actual=("$@")
local -a actual=(); [[ $# -gt 0 ]] && actual=("$@")

if [[ "${actual[*]}" == *"$expected"* ]]; then
if [[ "${actual[*]:-}" == *"$expected"* ]]; then
bashunit::assert::mark_failed
bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to not contain" "${expected}"
return
Expand Down
Loading
Loading