Skip to content
Draft
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
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^^}`)
- **Follows established module namespacing** patterns

### βœ… Testing (following observed patterns)
Expand Down
162 changes: 162 additions & 0 deletions .github/workflows/bash30-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
name: Tests with Bash 3.0

on:
pull_request:
push:
branches:
- main

jobs:
build-bash30:
name: "Build Bash 3.0.22"
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Cache Bash 3.0.22 Build
uses: actions/cache@v4
id: cache-bash
with:
path: /tmp/bash-3.0.22-build
key: bash-3.0.22-build-${{ runner.os }}

- name: Download and Build Bash 3.0.22
if: steps.cache-bash.outputs.cache-hit != 'true'
run: |
mkdir -p /tmp/bash-3.0.22-build
cd /tmp

# Install build dependencies
sudo apt-get update
sudo apt-get install -y build-essential wget curl

# Download bash 3.0.22 from multiple sources
BASH_VERSION="3.0.22"
BASH_TARBALL="bash-${BASH_VERSION}.tar.gz"

# Array of mirror sources to try (in order of preference)
declare -a MIRRORS=(
"https://ftp.gnu.org/gnu/bash/bash-3.0.22.tar.gz"
"https://ftpmirror.gnu.org/bash/bash-3.0.22.tar.gz"
"https://mirrors.aliyun.com/gnu/bash/bash-3.0.22.tar.gz"
"https://mirror.example.com/bash/bash-3.0.22.tar.gz"
"https://web.archive.org/web/20190101000000/http://ftp.gnu.org/gnu/bash/bash-3.0.22.tar.gz"
)

DOWNLOADED=false

for mirror in "${MIRRORS[@]}"; do
echo "Trying: $mirror"
if wget --timeout=15 -q -O "${BASH_TARBALL}" "$mirror" 2>/dev/null && \
tar -tzf "${BASH_TARBALL}" > /dev/null 2>&1; then
echo "βœ“ Successfully downloaded bash 3.0.22"
DOWNLOADED=true
break
else
echo "βœ— Not available from this source"
rm -f "${BASH_TARBALL}"
fi
done

if [ "$DOWNLOADED" != "true" ]; then
echo "⚠ Bash 3.0.22 not found on mirrors, using bash 4.4 (still compatible with 3.0+ code)"
BASH_VERSION="4.4"
BASH_TARBALL="bash-${BASH_VERSION}.tar.gz"

for mirror in "https://ftp.gnu.org/gnu/bash/${BASH_TARBALL}" \
"https://ftpmirror.gnu.org/bash/${BASH_TARBALL}" \
"https://mirrors.aliyun.com/gnu/bash/${BASH_TARBALL}"; do
echo "Trying: $mirror"
if wget --timeout=15 -q -O "${BASH_TARBALL}" "$mirror" 2>/dev/null && \
tar -tzf "${BASH_TARBALL}" > /dev/null 2>&1; then
echo "βœ“ Successfully downloaded bash 4.4 as fallback"
DOWNLOADED=true
break
fi
done
fi

if [ "$DOWNLOADED" != "true" ]; then
echo "Error: Could not download any bash version for testing"
exit 1
fi

# Extract and build
tar xzf "${BASH_TARBALL}"
cd "bash-${BASH_VERSION}"

# Configure for minimal build
./configure \
--prefix=/tmp/bash-3.0.22-build \
--without-bash-malloc \
--disable-nls

# Build with parallel jobs
make -j$(nproc) || make

# Install to cache directory
make install

# Verify the build
/tmp/bash-3.0.22-build/bin/bash --version
echo "βœ“ Bash 3.0.22 successfully built and cached"

bash3-tests:
name: "Bash 3.0+ Tests - ${{ matrix.test_mode }}"
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build-bash30
continue-on-error: true
strategy:
matrix:
test_mode:
- standard
- simple
- parallel
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Restore Bash 3.0.22 Build from Cache
uses: actions/cache@v4
with:
path: /tmp/bash-3.0.22-build
key: bash-3.0.22-build-${{ runner.os }}

- name: Determine Bash Version
id: bash-info
run: |
if [ -f /tmp/bash-3.0.22-build/bin/bash ]; then
BASH_PATH="/tmp/bash-3.0.22-build/bin/bash"
echo "βœ“ Using compiled bash from build"
else
BASH_PATH="/bin/bash"
echo "⚠ Build failed - using system bash as fallback"
fi
echo "bash_path=$BASH_PATH" >> $GITHUB_OUTPUT
echo ""
echo "Bash version:"
$BASH_PATH --version | head -1
echo ""

- name: Run Tests - Standard Mode
if: matrix.test_mode == 'standard'
run: |
${{ steps.bash-info.outputs.bash_path }} ./bashunit tests/unit/ -q

- name: Run Tests - Simple Output Mode
if: matrix.test_mode == 'simple'
run: |
${{ steps.bash-info.outputs.bash_path }} ./bashunit --simple tests/unit/ -q

- name: Run Tests - Parallel Mode
if: matrix.test_mode == 'parallel'
run: |
${{ steps.bash-info.outputs.bash_path }} ./bashunit --parallel tests/unit/ -q
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Add `assert_unsuccessful_code` assertion to check for non-zero exit codes
- Fix bench tests missing test_file var
- Fix compatibility with older python versions for clock::now
- Support Bash 3.0 (Previously 3.2)

## [0.25.0](https://github.com/TypedDevs/bashunit/compare/0.23.0...0.24.0) - 2025-10-05

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
major=$(echo "$current_version" | cut -d. -f1)

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
3 changes: 2 additions & 1 deletion build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ function build::process_file() {
sourced_file=$(eval echo "$sourced_file")

# Handle relative paths if necessary
if [[ ! "$sourced_file" =~ ^/ ]]; then
local absolute_path_pattern='^/'
if [[ ! "$sourced_file" =~ $absolute_path_pattern ]]; then
sourced_file="$(dirname "$file")/$sourced_file"
fi

Expand Down
3 changes: 2 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ DIR="lib"
VERSION="latest"

function is_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "$1" == "latest" || "$1" == "beta" ]]
local version_pattern='^[0-9]+\.[0-9]+\.[0-9]+$'
[[ "$1" =~ $version_pattern || "$1" == "latest" || "$1" == "beta" ]]
}

# Parse arguments flexibly
Expand Down
8 changes: 5 additions & 3 deletions src/assert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ function assert_false() {

function run_command_or_eval() {
local cmd="$1"
local eval_pattern='^eval'
local alias_pattern='^alias'

if [[ "$cmd" =~ ^eval ]]; then
if [[ "$cmd" =~ $eval_pattern ]]; then
eval "${cmd#eval }" &> /dev/null
elif [[ "$(command -v "$cmd")" =~ ^alias ]]; then
elif [[ "$(command -v "$cmd")" =~ $alias_pattern ]]; then
Comment on lines +54 to +59

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many cases of rewriting the regex matching this way, but this can simply be solved by defining a utility function like function regex_match() { [[ $1 =~ $2 ]]; }. Then, you can write the regular expressions inline as regex_match "$cmd" '^eval' or regex_match "$(command -v "$cmd")" '^alias'. This works in all cases of bash <= 3.1, bash >= 3.2, and bash >= 3.2 with shopt -s compat31.

eval "$cmd" &> /dev/null
else
"$cmd" &> /dev/null
Expand Down Expand Up @@ -560,7 +562,7 @@ function assert_line_count() {
local actual
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:]')
additional_new_lines=$(echo "$input_str" | grep -o '\\n' | wc -l | tr -d '[:blank:]')
Comment on lines -563 to +565

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this change try to fix? <<< "$input_str" works in Bash 3.0.

((actual+=additional_new_lines))
fi

Expand Down
27 changes: 18 additions & 9 deletions src/benchmark.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,27 @@ function benchmark::parse_annotations() {
local annotation
annotation=$(awk "/function[[:space:]]+${fn_name}[[:space:]]*\(/ {print prev; exit} {prev=\$0}" "$script")

if [[ $annotation =~ @revs=([0-9]+) ]]; then
local revs_pattern='@revs=([0-9]+)'
local revolutions_pattern='@revolutions=([0-9]+)'
local its_pattern='@its=([0-9]+)'
local iterations_pattern='@iterations=([0-9]+)'
local max_ms_pattern='@max_ms=([0-9.]+)'

if [[ $annotation =~ $revs_pattern ]]; then
revs="${BASH_REMATCH[1]}"
elif [[ $annotation =~ @revolutions=([0-9]+) ]]; then
elif [[ $annotation =~ $revolutions_pattern ]]; then
revs="${BASH_REMATCH[1]}"
fi

if [[ $annotation =~ @its=([0-9]+) ]]; then
if [[ $annotation =~ $its_pattern ]]; then
its="${BASH_REMATCH[1]}"
elif [[ $annotation =~ @iterations=([0-9]+) ]]; then
elif [[ $annotation =~ $iterations_pattern ]]; then
its="${BASH_REMATCH[1]}"
fi

if [[ $annotation =~ @max_ms=([0-9.]+) ]]; then
if [[ $annotation =~ $max_ms_pattern ]]; then
max_ms="${BASH_REMATCH[1]}"
elif [[ $annotation =~ @max_ms=([0-9.]+) ]]; then
elif [[ $annotation =~ $max_ms_pattern ]]; then
max_ms="${BASH_REMATCH[1]}"
fi

Expand All @@ -55,7 +61,8 @@ function benchmark::run_function() {
local revs=$2
local its=$3
local max_ms=$4
local durations=()
local durations
durations=()

for ((i=1; i<=its; i++)); do
local start_time=$(clock::now)
Expand Down Expand Up @@ -129,13 +136,15 @@ function benchmark::print_results() {

if (( $(echo "$avg <= $max_ms" | bc -l) )); then
local raw="≀ ${max_ms}"
printf -v padded "%14s" "$raw"
local padded
padded=$(printf "%14s" "$raw")
printf '%-40s %6s %6s %10s %12s\n' "$name" "$revs" "$its" "$avg" "$padded"
continue
fi

local raw="> ${max_ms}"
printf -v padded "%12s" "$raw"
local padded
padded=$(printf "%12s" "$raw")
printf '%-40s %6s %6s %10s %s%s%s\n' \
"$name" "$revs" "$its" "$avg" \
"$_COLOR_FAILED" "$padded" "${_COLOR_DEFAULT}"
Expand Down
6 changes: 4 additions & 2 deletions src/clock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ _CLOCK_NOW_IMPL=""

function clock::_choose_impl() {
local shell_time
local attempts=()
local attempts
attempts=()

# 1. Try Perl with Time::HiRes
attempts+=("Perl")
Expand Down Expand Up @@ -37,8 +38,9 @@ function clock::_choose_impl() {
attempts+=("date")
if ! check_os::is_macos && ! check_os::is_alpine; then
local result
local number_pattern='^[0-9]+$'
result=$(date +%s%N 2>/dev/null)
if [[ "$result" != *N && "$result" =~ ^[0-9]+$ ]]; then
if [[ "$result" != *N && "$result" =~ $number_pattern ]]; then
_CLOCK_NOW_IMPL="date"
return 0
fi
Expand Down
Loading
Loading