├── .nvmrc ├── .tasks └── .gitkeep ├── .gitmodules ├── src ├── test_title.sh ├── io.sh ├── assertions.sh ├── skip_todo.sh ├── dev │ └── debug.sh ├── math.sh ├── upgrade.sh ├── bashunit.sh ├── dependencies.sh ├── assert_arrays.sh ├── init.sh ├── doc.sh ├── colors.sh ├── check_os.sh ├── str.sh ├── assert_snapshot.sh ├── globals.sh ├── parallel.sh ├── clock.sh ├── benchmark.sh ├── assert_files.sh ├── test_doubles.sh ├── assert_folders.sh ├── reports.sh ├── env.sh ├── state.sh ├── console_header.sh ├── console_results.sh ├── helpers.sh ├── main.sh └── assert.sh ├── shell.nix ├── bpkg.json ├── .env.example ├── package.json ├── LICENSE ├── adrs ├── adr-004-metadata-prefix.md ├── adr-002-using-booleans.md ├── adr-003-parallel-testing.md ├── adr-001-changing-error-detection-mechanism.md ├── TEMPLATE.md └── adr-005-copilot-instruction-or-spec-kit.md ├── README.md ├── Makefile ├── install.sh ├── bashunit ├── AGENTS.md └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/jod 2 | -------------------------------------------------------------------------------- /.tasks/.gitkeep: -------------------------------------------------------------------------------- 1 | # Tasks directory 2 | This directory contains task files for tracking development work. 3 | 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "example/tools/bashunit"] 2 | path = example/tools/bashunit 3 | url = git@github.com:TypedDevs/bashunit.git 4 | -------------------------------------------------------------------------------- /src/test_title.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function bashunit::set_test_title() { 4 | bashunit::state::set_test_title "$1" 5 | } 6 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | pkgs.mkShell { 4 | buildInputs = [ 5 | pkgs.bashInteractive 6 | pkgs.git 7 | pkgs.curl 8 | pkgs.perl 9 | ]; 10 | } 11 | -------------------------------------------------------------------------------- /bpkg.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bashunit", 3 | "description": "A simple testing library for bash scripts.", 4 | "scripts": [ "bashunit" ], 5 | "install": "install -b bashunit ${PREFIX:-/usr/local}/bin/bashunit", 6 | "global": "false" 7 | } 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | BASHUNIT_DEFAULT_PATH= 2 | BASHUNIT_DEV_LOG= 3 | BASHUNIT_BOOTSTRAP= 4 | BASHUNIT_LOG_JUNIT= 5 | BASHUNIT_REPORT_HTML= 6 | 7 | # Booleans 8 | BASHUNIT_PARALLEL_RUN= 9 | BASHUNIT_SHOW_HEADER= 10 | BASHUNIT_HEADER_ASCII_ART= 11 | BASHUNIT_SIMPLE_OUTPUT= 12 | BASHUNIT_STOP_ON_FAILURE= 13 | BASHUNIT_SHOW_EXECUTION_TIME= 14 | -------------------------------------------------------------------------------- /src/io.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function bashunit::io::download_to() { 4 | local url="$1" 5 | local output="$2" 6 | if bashunit::dependencies::has_curl; then 7 | curl -L -J -o "$output" "$url" 2>/dev/null 8 | elif bashunit::dependencies::has_wget; then 9 | wget -q -O "$output" "$url" 2>/dev/null 10 | else 11 | return 1 12 | fi 13 | } 14 | -------------------------------------------------------------------------------- /src/assertions.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source "$BASHUNIT_ROOT_DIR/src/assert.sh" 4 | source "$BASHUNIT_ROOT_DIR/src/assert_arrays.sh" 5 | source "$BASHUNIT_ROOT_DIR/src/assert_files.sh" 6 | source "$BASHUNIT_ROOT_DIR/src/assert_folders.sh" 7 | source "$BASHUNIT_ROOT_DIR/src/assert_snapshot.sh" 8 | source "$BASHUNIT_ROOT_DIR/src/skip_todo.sh" 9 | source "$BASHUNIT_ROOT_DIR/src/test_doubles.sh" 10 | -------------------------------------------------------------------------------- /src/skip_todo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function bashunit::skip() { 4 | local reason=${1-} 5 | local label 6 | label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")" 7 | 8 | bashunit::console_results::print_skipped_test "${label}" "${reason}" 9 | 10 | bashunit::state::add_assertions_skipped 11 | } 12 | 13 | function bashunit::todo() { 14 | local pending=${1-} 15 | local label 16 | label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")" 17 | 18 | bashunit::console_results::print_incomplete_test "${label}" "${pending}" 19 | 20 | bashunit::state::add_assertions_incomplete 21 | } 22 | -------------------------------------------------------------------------------- /src/dev/debug.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # An alternative to echo when debugging. 4 | # This is debug function; do not use in prod! 5 | function bashunit::dump() { 6 | printf "[%s] %s: %s\n" "${_BASHUNIT_COLOR_SKIPPED}DUMP${_BASHUNIT_COLOR_DEFAULT}" \ 7 | "${_BASHUNIT_COLOR_PASSED}${BASH_SOURCE[1]}:${BASH_LINENO[0]}" \ 8 | "${_BASHUNIT_COLOR_DEFAULT}$*" 9 | } 10 | 11 | # Dump and Die. 12 | function bashunit::dd() { 13 | printf "[%s] %s: %s\n" "${_BASHUNIT_COLOR_FAILED}DUMP${_BASHUNIT_COLOR_DEFAULT}" \ 14 | "${_BASHUNIT_COLOR_PASSED}${BASH_SOURCE[1]}:${BASH_LINENO[0]}" \ 15 | "${_BASHUNIT_COLOR_DEFAULT}$*" 16 | 17 | kill -9 $$ 18 | } 19 | -------------------------------------------------------------------------------- /src/math.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function bashunit::math::calculate() { 4 | local expr="$*" 5 | 6 | if bashunit::dependencies::has_bc; then 7 | echo "$expr" | bc 8 | return 9 | fi 10 | 11 | if [[ "$expr" == *.* ]]; then 12 | if bashunit::dependencies::has_awk; then 13 | awk "BEGIN { print ($expr) }" 14 | return 15 | fi 16 | # Downgrade to integer math by stripping decimals 17 | expr=$(echo "$expr" | sed -E 's/([0-9]+)\.[0-9]+/\1/g') 18 | fi 19 | 20 | # Remove leading zeros from integers 21 | expr=$(echo "$expr" | sed -E 's/\b0*([1-9][0-9]*)/\1/g') 22 | 23 | local result=$(( expr )) 24 | echo "$result" 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bashunit-docs", 3 | "version": "0.29.0", 4 | "checksum": "90d5afc07222920777d6c47af657d1d7a80a0055eaaa506c9c814f6b224da102", 5 | "description": "Docs for bashunit a simple testing library for bash scripts", 6 | "main": "index.js", 7 | "repository": "git@github.com:TypedDevs/bashunit.git", 8 | "author": "TypedDevs ", 9 | "license": "MIT", 10 | "type": "module", 11 | "scripts": { 12 | "docs:dev": "vitepress dev docs", 13 | "docs:build": "vitepress build docs", 14 | "docs:preview": "vitepress preview docs" 15 | }, 16 | "dependencies": { 17 | "chart.js": "^4.4.9", 18 | "vanilla-tilt": "^1.8.1" 19 | }, 20 | "devDependencies": { 21 | "vitepress": "^1.6.3", 22 | "vue": "^3.5.16" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/upgrade.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function bashunit::upgrade::upgrade() { 4 | local script_path 5 | script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 | local latest_tag 7 | latest_tag="$(bashunit::helper::get_latest_tag)" 8 | 9 | if [[ "$BASHUNIT_VERSION" == "$latest_tag" ]]; then 10 | echo "> You are already on latest version" 11 | return 12 | fi 13 | 14 | echo "> Upgrading bashunit to latest version" 15 | cd "$script_path" || exit 16 | 17 | local url="https://github.com/TypedDevs/bashunit/releases/download/$latest_tag/bashunit" 18 | if ! bashunit::io::download_to "$url" "bashunit"; then 19 | echo "Failed to download bashunit" 20 | fi 21 | 22 | chmod u+x "bashunit" 23 | 24 | echo "> bashunit upgraded successfully to latest version $latest_tag" 25 | } 26 | -------------------------------------------------------------------------------- /src/bashunit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This file provides a facade to developers who wants 4 | # to interact with the internals of bashunit. 5 | # e.g. adding custom assertions 6 | 7 | function bashunit::assertion_failed() { 8 | bashunit::assert::should_skip && return 0 9 | 10 | local expected=$1 11 | local actual=$2 12 | local failure_condition_message=${3:-"but got "} 13 | 14 | local test_fn 15 | test_fn="$(bashunit::helper::find_test_function_name)" 16 | local label 17 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 18 | bashunit::assert::mark_failed 19 | bashunit::console_results::print_failed_test "${label}" "${expected}" \ 20 | "$failure_condition_message" "${actual}" 21 | } 22 | 23 | function bashunit::assertion_passed() { 24 | bashunit::assert::should_skip && return 0 25 | 26 | bashunit::state::add_assertions_passed 27 | } 28 | -------------------------------------------------------------------------------- /src/dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | function bashunit::dependencies::has_perl() { 5 | command -v perl >/dev/null 2>&1 6 | } 7 | 8 | function bashunit::dependencies::has_powershell() { 9 | command -v powershell > /dev/null 2>&1 10 | } 11 | 12 | function bashunit::dependencies::has_adjtimex() { 13 | command -v adjtimex >/dev/null 2>&1 14 | } 15 | 16 | function bashunit::dependencies::has_bc() { 17 | command -v bc >/dev/null 2>&1 18 | } 19 | 20 | function bashunit::dependencies::has_awk() { 21 | command -v awk >/dev/null 2>&1 22 | } 23 | 24 | function bashunit::dependencies::has_git() { 25 | command -v git >/dev/null 2>&1 26 | } 27 | 28 | function bashunit::dependencies::has_curl() { 29 | command -v curl >/dev/null 2>&1 30 | } 31 | 32 | function bashunit::dependencies::has_wget() { 33 | command -v wget >/dev/null 2>&1 34 | } 35 | 36 | function bashunit::dependencies::has_python() { 37 | command -v python >/dev/null 2>&1 38 | } 39 | 40 | function bashunit::dependencies::has_node() { 41 | command -v node >/dev/null 2>&1 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 TypedDevs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /adrs/adr-004-metadata-prefix.md: -------------------------------------------------------------------------------- 1 | # Title: Prefix metadata comments with @ 2 | 3 | * Status: accepted 4 | * Authors: @Chemaclass 5 | * Date: 2025-05-29 6 | 7 | 8 | ## Context and Problem Statement 9 | 10 | Data providers are defined via a special comment `# data_provider`. We want to 11 | clearly differentiate these meta comments from ordinary comments. 12 | 13 | ## Considered Options 14 | 15 | * Keep using `# data_provider` as is. 16 | * Introduce an `@` prefix for special comments while supporting the old syntax. 17 | 18 | ## Decision Outcome 19 | 20 | We decided to prefix the metadata provider directives with `@`, 21 | eg: using `# @data_provider provider_name`. 22 | 23 | > The previous form without the prefix is still supported for backward compatibility but is now deprecated. 24 | 25 | ### Positive Consequences 26 | 27 | * Highlights special bashunit directives clearly. 28 | * Allows future directives to consistently use the `@` prefix. 29 | 30 | ### Negative Consequences 31 | 32 | * Projects must eventually update old comments to the new syntax. 33 | 34 | ## Technical Details 35 | 36 | `helper::get_provider_data` now matches both `# @data_provider` and the old 37 | `# data_provider` when locating provider functions. 38 | -------------------------------------------------------------------------------- /src/assert_arrays.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function assert_array_contains() { 4 | bashunit::assert::should_skip && return 0 5 | 6 | local expected="$1" 7 | local test_fn 8 | test_fn="$(bashunit::helper::find_test_function_name)" 9 | local label 10 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 11 | shift 12 | 13 | local actual=("${@}") 14 | 15 | if ! [[ "${actual[*]}" == *"$expected"* ]]; then 16 | bashunit::assert::mark_failed 17 | bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to contain" "${expected}" 18 | return 19 | fi 20 | 21 | bashunit::state::add_assertions_passed 22 | } 23 | 24 | function assert_array_not_contains() { 25 | bashunit::assert::should_skip && return 0 26 | 27 | local expected="$1" 28 | local test_fn 29 | test_fn="$(bashunit::helper::find_test_function_name)" 30 | local label 31 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 32 | shift 33 | local actual=("$@") 34 | 35 | if [[ "${actual[*]}" == *"$expected"* ]]; then 36 | bashunit::assert::mark_failed 37 | bashunit::console_results::print_failed_test "${label}" "${actual[*]}" "to not contain" "${expected}" 38 | return 39 | fi 40 | 41 | bashunit::state::add_assertions_passed 42 | } 43 | -------------------------------------------------------------------------------- /src/init.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function bashunit::init::project() { 4 | local tests_dir="${1:-$BASHUNIT_DEFAULT_PATH}" 5 | mkdir -p "$tests_dir" 6 | 7 | local bootstrap_file="$tests_dir/bootstrap.sh" 8 | if [[ ! -f "$bootstrap_file" ]]; then 9 | cat >"$bootstrap_file" <<'SH' 10 | #!/usr/bin/env bash 11 | set -euo pipefail 12 | # Place your common test setup here 13 | SH 14 | chmod +x "$bootstrap_file" 15 | echo "> Created $bootstrap_file" 16 | fi 17 | 18 | local example_test="$tests_dir/example_test.sh" 19 | if [[ ! -f "$example_test" ]]; then 20 | cat >"$example_test" <<'SH' 21 | #!/usr/bin/env bash 22 | 23 | function test_bashunit_is_installed() { 24 | assert_same "bashunit is installed" "bashunit is installed" 25 | } 26 | SH 27 | chmod +x "$example_test" 28 | echo "> Created $example_test" 29 | fi 30 | 31 | local env_file=".env" 32 | local env_line="BASHUNIT_BOOTSTRAP=$bootstrap_file" 33 | if [[ -f "$env_file" ]]; then 34 | if grep -q "^BASHUNIT_BOOTSTRAP=" "$env_file"; then 35 | if bashunit::check_os::is_macos; then 36 | sed -i '' -e "s/^BASHUNIT_BOOTSTRAP=/#&/" "$env_file" 37 | else 38 | sed -i -e "s/^BASHUNIT_BOOTSTRAP=/#&/" "$env_file" 39 | fi 40 | fi 41 | echo "$env_line" >> "$env_file" 42 | else 43 | echo "$env_line" > "$env_file" 44 | fi 45 | 46 | echo "> bashunit initialized in $tests_dir" 47 | } 48 | -------------------------------------------------------------------------------- /src/doc.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This function returns the embedded assertions.md content. 4 | # During development, it reads from the file. 5 | # During build, this function is replaced with actual content. 6 | function bashunit::doc::get_embedded_docs() { 7 | # __BASHUNIT_EMBEDDED_DOCS_START__ 8 | cat "$BASHUNIT_ROOT_DIR/docs/assertions.md" 9 | # __BASHUNIT_EMBEDDED_DOCS_END__ 10 | } 11 | 12 | function bashunit::doc::print_asserts() { 13 | local filter="${1:-}" 14 | local line 15 | local docstring="" 16 | local fn="" 17 | local should_print=0 18 | 19 | while IFS='' read -r line || [[ -n "$line" ]]; do 20 | if [[ $line =~ ^##\ ([A-Za-z0-9_]+) ]]; then 21 | fn="${BASH_REMATCH[1]}" 22 | if [[ -z "$filter" || "$fn" == *"$filter"* ]]; then 23 | should_print=1 24 | echo "$line" 25 | docstring="" 26 | else 27 | should_print=0 28 | fi 29 | continue 30 | fi 31 | 32 | if (( should_print )); then 33 | if [[ "$line" =~ ^\`\`\` ]]; then 34 | echo "--------------" 35 | echo "$docstring" 36 | should_print=0 37 | continue 38 | fi 39 | 40 | [[ "$line" == "::: code-group"* ]] && continue 41 | 42 | # Remove markdown link brackets and anchor tags 43 | line="${line//[\[\]]/}" 44 | line="$(sed -E 's/ *\(#[-a-z0-9]+\)//g' <<< "$line")" 45 | docstring+="$line"$'\n' 46 | fi 47 | done <<< "$(bashunit::doc::get_embedded_docs)" 48 | } 49 | -------------------------------------------------------------------------------- /adrs/adr-002-using-booleans.md: -------------------------------------------------------------------------------- 1 | # Title: Using native bash booleans 2 | 3 | * Status: accepted 4 | * Authors: @Chemaclass 5 | * Date: 2024-10-03 6 | 7 | Technical Story: 8 | - Pull Request: [TypedDevs/bashunit#345](https://github.com/TypedDevs/bashunit/pull/345#discussion_r1782226289) 9 | 10 | ## Context and Problem Statement 11 | 12 | We are using booleans with different syntax in different parts of the project. 13 | 14 | ## Considered Options 15 | 16 | * Use true and false as `0`, `1` native shell booleans 17 | * Use true and false as strings: `"true"`, `"false"` 18 | * Use true and false as native programs: `true`, `false` 19 | 20 | ## Decision Outcome 21 | 22 | To keep consistency in the project, we want to use the standard and best practices of booleans while 23 | keeping a great DX. 24 | 25 | When using return, we must use a number: 26 | - `return 0` # for success 27 | - `return 1` # for failure 28 | 29 | When using variables, we must use `true` and `false` as commands (not strings!): 30 | - `true` is a command that always returns a successful exit code (0) 31 | - `false` is a command that always returns a failure exit code (1) 32 | 33 | When possible, extract a condition into a function. For example: 34 | ```bash 35 | function env::is_show_header_enabled() { 36 | # this is a string comparison because it is coming from the .env 37 | [[ "$BASHUNIT_SHOW_HEADER" == "true" ]] 38 | } 39 | ``` 40 | Usage 41 | ```bash 42 | if env::is_show_header_enabled; then 43 | # ... 44 | fi 45 | ``` 46 | 47 | ### Positive Consequences 48 | 49 | We keep the native shell boolean syntax in conditions. 50 | 51 | ### Negative Consequences 52 | 53 | Not that I am aware of. 54 | -------------------------------------------------------------------------------- /adrs/adr-003-parallel-testing.md: -------------------------------------------------------------------------------- 1 | # Title: Parallel testing 2 | 3 | * Status: accepted 4 | * Authors: @Chemaclass 5 | * Date: 2024-10-11 6 | 7 | Technical Story: 8 | - Pull Request: [TypedDevs/bashunit#358](https://github.com/TypedDevs/bashunit/pull/358) 9 | 10 | ## Context and Problem Statement 11 | 12 | We aim to enhance testing performance by running tests in parallel processes while capturing and aggregating results effectively. 13 | 14 | ## Considered Options 15 | 16 | - Implement parallel execution using subprocesses. 17 | - Aggregate test results from temporary files. 18 | - Use a spinner for user feedback during result aggregation. 19 | 20 | ## Decision Outcome 21 | 22 | - Implemented parallel test execution using subprocesses. 23 | - Each test creates a temporary directory to store results, later aggregated. 24 | 25 | ### Positive Consequences 26 | 27 | - Reduced test execution time considerably. 28 | - Clear feedback via a spinner during aggregation. 29 | 30 | ### Negative Consequences 31 | 32 | - Potential complexity 33 | - with handling temporary files during interruptions. 34 | - in handling temporary files and managing subprocesses. 35 | 36 | ## Technical Details 37 | 38 | When the `--parallel` flag is used, each test is run in its own subprocess by calling: 39 | 40 | > runner::call_test_functions "$test_file" "$filter" 2>/dev/null & 41 | 42 | Each test script creates a temporary directory and stores individual test results in temp files. 43 | After all tests finish, the results are aggregated by traversing these directories and files. 44 | This approach ensures isolation of test execution while improving performance by running tests concurrently. 45 | 46 | The aggregation (which collects all test outcomes into a final result set) is handled by the function: 47 | 48 | > parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE" 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/colors.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Pass in any number of ANSI SGR codes. 4 | # 5 | # Code reference: 6 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters 7 | # Credit: 8 | # https://superuser.com/a/1119396 9 | bashunit::sgr() { 10 | local codes=${1:-0} 11 | shift 12 | 13 | for c in "$@"; do 14 | codes="$codes;$c" 15 | done 16 | 17 | echo $'\e'"[${codes}m" 18 | } 19 | 20 | if bashunit::env::is_no_color_enabled; then 21 | _BASHUNIT_COLOR_BOLD="" 22 | _BASHUNIT_COLOR_FAINT="" 23 | _BASHUNIT_COLOR_BLACK="" 24 | _BASHUNIT_COLOR_FAILED="" 25 | _BASHUNIT_COLOR_PASSED="" 26 | _BASHUNIT_COLOR_SKIPPED="" 27 | _BASHUNIT_COLOR_INCOMPLETE="" 28 | _BASHUNIT_COLOR_SNAPSHOT="" 29 | _BASHUNIT_COLOR_RETURN_ERROR="" 30 | _BASHUNIT_COLOR_RETURN_SUCCESS="" 31 | _BASHUNIT_COLOR_RETURN_SKIPPED="" 32 | _BASHUNIT_COLOR_RETURN_INCOMPLETE="" 33 | _BASHUNIT_COLOR_RETURN_SNAPSHOT="" 34 | _BASHUNIT_COLOR_DEFAULT="" 35 | else 36 | _BASHUNIT_COLOR_BOLD="$(bashunit::sgr 1)" 37 | _BASHUNIT_COLOR_FAINT="$(bashunit::sgr 2)" 38 | _BASHUNIT_COLOR_BLACK="$(bashunit::sgr 30)" 39 | _BASHUNIT_COLOR_FAILED="$(bashunit::sgr 31)" 40 | _BASHUNIT_COLOR_PASSED="$(bashunit::sgr 32)" 41 | _BASHUNIT_COLOR_SKIPPED="$(bashunit::sgr 33)" 42 | _BASHUNIT_COLOR_INCOMPLETE="$(bashunit::sgr 36)" 43 | _BASHUNIT_COLOR_SNAPSHOT="$(bashunit::sgr 34)" 44 | _BASHUNIT_COLOR_RETURN_ERROR="$(bashunit::sgr 41)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" 45 | _BASHUNIT_COLOR_RETURN_SUCCESS="$(bashunit::sgr 42)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" 46 | _BASHUNIT_COLOR_RETURN_SKIPPED="$(bashunit::sgr 43)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" 47 | _BASHUNIT_COLOR_RETURN_INCOMPLETE="$(bashunit::sgr 46)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" 48 | _BASHUNIT_COLOR_RETURN_SNAPSHOT="$(bashunit::sgr 44)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD" 49 | _BASHUNIT_COLOR_DEFAULT="$(bashunit::sgr 0)" 50 | fi 51 | -------------------------------------------------------------------------------- /src/check_os.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # shellcheck disable=SC2034 4 | _BASHUNIT_OS="Unknown" 5 | _BASHUNIT_DISTRO="Unknown" 6 | 7 | function bashunit::check_os::init() { 8 | if bashunit::check_os::is_linux; then 9 | _BASHUNIT_OS="Linux" 10 | if bashunit::check_os::is_ubuntu; then 11 | _BASHUNIT_DISTRO="Ubuntu" 12 | elif bashunit::check_os::is_alpine; then 13 | _BASHUNIT_DISTRO="Alpine" 14 | elif bashunit::check_os::is_nixos; then 15 | _BASHUNIT_DISTRO="NixOS" 16 | else 17 | _BASHUNIT_DISTRO="Other" 18 | fi 19 | elif bashunit::check_os::is_macos; then 20 | _BASHUNIT_OS="OSX" 21 | elif bashunit::check_os::is_windows; then 22 | _BASHUNIT_OS="Windows" 23 | else 24 | _BASHUNIT_OS="Unknown" 25 | _BASHUNIT_DISTRO="Unknown" 26 | fi 27 | } 28 | 29 | function bashunit::check_os::is_ubuntu() { 30 | command -v apt > /dev/null 31 | } 32 | 33 | function bashunit::check_os::is_alpine() { 34 | command -v apk > /dev/null 35 | } 36 | 37 | function bashunit::check_os::is_nixos() { 38 | [[ -f /etc/NIXOS ]] && return 0 39 | grep -q '^ID=nixos' /etc/os-release 2>/dev/null 40 | } 41 | 42 | function bashunit::check_os::is_linux() { 43 | [[ "$(uname)" == "Linux" ]] 44 | } 45 | 46 | function bashunit::check_os::is_macos() { 47 | [[ "$(uname)" == "Darwin" ]] 48 | } 49 | 50 | function bashunit::check_os::is_windows() { 51 | case "$(uname)" in 52 | *MINGW*|*MSYS*|*CYGWIN*) 53 | return 0 54 | ;; 55 | *) 56 | return 1 57 | ;; 58 | esac 59 | } 60 | 61 | function bashunit::check_os::is_busybox() { 62 | 63 | case "$_BASHUNIT_DISTRO" in 64 | 65 | "Alpine") 66 | return 0 67 | ;; 68 | *) 69 | return 1 70 | ;; 71 | esac 72 | } 73 | 74 | bashunit::check_os::init 75 | 76 | export _BASHUNIT_OS 77 | export _BASHUNIT_DISTRO 78 | export -f bashunit::check_os::is_alpine 79 | export -f bashunit::check_os::is_busybox 80 | export -f bashunit::check_os::is_ubuntu 81 | export -f bashunit::check_os::is_nixos 82 | -------------------------------------------------------------------------------- /adrs/adr-001-changing-error-detection-mechanism.md: -------------------------------------------------------------------------------- 1 | # Title: Changing Error Detection Mechanism in Bashunit 2 | 3 | * Status: accepted 4 | * Authors: @Tito-Kati, with consensus from @khru and @Chemaclass 5 | * Date: 2023-10-14 6 | 7 | Technical Story: 8 | - Issue: [TypedDevs/bashunit#182](https://github.com/TypedDevs/bashunit/issues/182) 9 | - Pull Request: [TypedDevs/bashunit#189](https://github.com/TypedDevs/bashunit/pull/189) 10 | 11 | ## Context and Problem Statement 12 | 13 | In the existing setup of bashunit, error detection within tests was based on return codes along with `set -e`. 14 | This mechanism would interrupt a test script if any execution within the script returned an error code other than 0. 15 | A specific scenario was identified where a non-existing function call within a test did not cause the test to fail as it should, as illustrated in issue [#182](https://github.com/TypedDevs/bashunit/issues/182). 16 | 17 | ## Considered Options 18 | * Use stderr instead return codes and set -e. 19 | 20 | ## Decision Outcome 21 | 22 | To rectify this, a new error detection mechanism was proposed in pull request [#189](https://github.com/TypedDevs/bashunit/pull/189). 23 | The changes shifted error detection from relying on return codes to utilizing stderr. 24 | Now, if any execution within a script writes something to stderr, it will be considered as failed. 25 | This adjustment also changes the behavior of the test runner slightly as tests will now run to the end even if there’s a failure at the beginning, aligning the behavior across different scenarios. 26 | 27 | ### Positive Consequences 28 | 29 | The consequences include: 30 | - Enabling true Test Driven Development (TDD) in bashunit by ensuring that tests fail as expected when there's an error, providing a more accurate and reliable testing environment. 31 | - Altering the runner's behavior to continue executing tests even after an initial failure, which may be viewed as strange but is consistent with the new error detection mechanism. 32 | - Refining error reporting to align with standard practices, providing more descriptive insight into the errors. 33 | 34 | ### Negative Consequences 35 | 36 | Unknown at the moment. 37 | -------------------------------------------------------------------------------- /adrs/TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # [short title of solved problem and solution] 2 | 3 | * Status: [proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)] 4 | * Deciders: [list everyone involved in the decision] 5 | * Date: [YYYY-MM-DD when the decision was last updated] 6 | 7 | Technical Story: [description | ticket/issue URL] 8 | 9 | ## Context and Problem Statement 10 | 11 | [Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] 12 | 13 | ## Decision Drivers 14 | 15 | * [driver 1, e.g., a force, facing concern, …] 16 | * [driver 2, e.g., a force, facing concern, …] 17 | * … 18 | 19 | ## Considered Options 20 | 21 | * [option 1] 22 | * [option 2] 23 | * [option 3] 24 | * … 25 | 26 | ## Decision Outcome 27 | 28 | Chosen option: "[option 1]", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)]. 29 | 30 | ### Positive Consequences 31 | 32 | * [e.g., improvement of quality attribute satisfaction, follow-up decisions required, …] 33 | * … 34 | 35 | ### Negative Consequences 36 | 37 | * [e.g., compromising quality attribute, follow-up decisions required, …] 38 | * … 39 | 40 | ## Pros and Cons of the Options 41 | 42 | ### [option 1] 43 | 44 | [example | description | pointer to more information | …] 45 | 46 | * Good, because [argument a] 47 | * Good, because [argument b] 48 | * Bad, because [argument c] 49 | * … 50 | 51 | ### [option 2] 52 | 53 | [example | description | pointer to more information | …] 54 | 55 | * Good, because [argument a] 56 | * Good, because [argument b] 57 | * Bad, because [argument c] 58 | * … 59 | 60 | ### [option 3] 61 | 62 | [example | description | pointer to more information | …] 63 | 64 | * Good, because [argument a] 65 | * Good, because [argument b] 66 | * Bad, because [argument c] 67 | * … 68 | 69 | ## Links 70 | 71 | * [Link type] [Link to ADR] 72 | * … 73 | -------------------------------------------------------------------------------- /src/str.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Strip ANSI escape codes and control characters 4 | function bashunit::str::strip_ansi() { 5 | local input="$1" 6 | echo -e "$input" | sed -E 's/\x1B\[[0-9;]*[mK]//g; s/[[:cntrl:]]//g' 7 | } 8 | 9 | function bashunit::str::rpad() { 10 | local left_text="$1" 11 | local right_word="$2" 12 | local width_padding="${3:-$TERMINAL_WIDTH}" 13 | # Subtract 1 more to account for the extra space 14 | local padding=$((width_padding - ${#right_word} - 1)) 15 | if (( padding < 0 )); then 16 | padding=0 17 | fi 18 | 19 | # Remove ANSI escape sequences (non-visible characters) for length calculation 20 | # shellcheck disable=SC2155 21 | local clean_left_text=$(bashunit::str::strip_ansi "$left_text") 22 | 23 | local is_truncated=false 24 | # If the visible left text exceeds the padding, truncate it and add "..." 25 | if [[ ${#clean_left_text} -gt $padding ]]; then 26 | local truncation_length=$((padding < 3 ? 0 : padding - 3)) 27 | clean_left_text="${clean_left_text:0:$truncation_length}" 28 | is_truncated=true 29 | fi 30 | 31 | # Rebuild the text with ANSI codes intact, preserving the truncation 32 | local result_left_text="" 33 | local i=0 34 | local j=0 35 | while [[ $i -lt ${#clean_left_text} && $j -lt ${#left_text} ]]; do 36 | local char="${clean_left_text:$i:1}" 37 | local original_char="${left_text:$j:1}" 38 | 39 | # If the current character is part of an ANSI sequence, skip it and copy it 40 | if [[ "$original_char" == $'\x1b' ]]; then 41 | while [[ "${left_text:$j:1}" != "m" && $j -lt ${#left_text} ]]; do 42 | result_left_text+="${left_text:$j:1}" 43 | ((j++)) 44 | done 45 | result_left_text+="${left_text:$j:1}" # Append the final 'm' 46 | ((j++)) 47 | elif [[ "$char" == "$original_char" ]]; then 48 | # Match the actual character 49 | result_left_text+="$char" 50 | ((i++)) 51 | ((j++)) 52 | else 53 | ((j++)) 54 | fi 55 | done 56 | 57 | local remaining_space 58 | if $is_truncated ; then 59 | result_left_text+="..." 60 | # 1: due to a blank space 61 | # 3: due to the appended ... 62 | remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1 - 3)) 63 | else 64 | # Copy any remaining characters after the truncation point 65 | result_left_text+="${left_text:$j}" 66 | remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1)) 67 | fi 68 | 69 | # Ensure the right word is placed exactly at the far right of the screen 70 | # filling the remaining space with padding 71 | if [[ $remaining_space -lt 0 ]]; then 72 | remaining_space=0 73 | fi 74 | 75 | printf "%s%${remaining_space}s %s\n" "$result_left_text" "" "$right_word" 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Tests 4 | 5 | 6 | Static analysis 7 | 8 | 9 | Publish Docs 10 | 11 | 12 | Editorconfig checker 13 | 14 | 15 | MIT Software License 16 | 17 | 18 | Ask DeepWiki 19 | 20 |

21 |
22 |

23 | 24 | 25 | bashunit 26 | 27 |

28 | 29 |

A simple testing framework for bash scripts

30 | 31 |

32 | Test your bash scripts in the fastest and simplest way, discover the most modern bash testing framework. 33 |

34 | 35 | ## Description 36 | 37 | **bashunit** is a comprehensive and lightweight testing framework for Bash, focused on the development experience. 38 | It boasts hundreds of assertions and functionalities like spies, mocks, providers and more, offers concise and clear documentation, and has a very active community. 39 | 40 | ## Documentation 41 | 42 | You can find the complete documentation for **bashunit** online, including installation instructions and the various features it provides, in the [official bashunit documentation](https://bashunit.typeddevs.com). 43 | 44 | ## Requirements 45 | 46 | bashunit requires **Bash 3.2** or newer. 47 | 48 | ## Contribute 49 | 50 | You are welcome to contribute reporting issues, sharing ideas, 51 | or with your pull requests. 52 | 53 | Make sure to read our [contribution guide](.github/CONTRIBUTING.md) where you will find, among other things, how to set up your environment with the various tools we use to develop this framework. 54 | 55 | ## Contributors 56 | 57 |

58 | Contributors list 59 |

60 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | 3 | -include .env 4 | 5 | STATIC_ANALYSIS_CHECKER := $(shell which shellcheck 2> /dev/null) 6 | LINTER_CHECKER := $(shell which editorconfig-checker 2> /dev/null) 7 | GIT_DIR = $(shell git rev-parse --git-dir 2> /dev/null) 8 | 9 | OS:= 10 | ifeq ($(OS),Windows_NT) 11 | OS +=WIN32 12 | ifeq ($(PROCESSOR_ARCHITECTURE),AMD64) 13 | OS +=_AMD64 14 | endif 15 | ifeq ($(PROCESSOR_ARCHITECTURE),x86) 16 | OS +=_IA32 17 | endif 18 | else 19 | UNAME_S := $(shell uname -s) 20 | ifeq ($(UNAME_S),Linux) 21 | OS+=LINUX 22 | endif 23 | ifeq ($(UNAME_S),Darwin) 24 | OS+=OSX 25 | endif 26 | UNAME_P := $(shell uname -p) 27 | ifeq ($(UNAME_P),x86_64) 28 | OS +=_AMD64 29 | endif 30 | ifneq ($(filter %86,$(UNAME_P)),) 31 | OS+=_IA32 32 | endif 33 | ifneq ($(filter arm%,$(UNAME_P)),) 34 | OS+=_ARM 35 | endif 36 | endif 37 | 38 | help: 39 | @echo "" 40 | @echo "Usage: make [command]" 41 | @echo "" 42 | @echo "Commands:" 43 | @echo " test Run the tests" 44 | @echo " test/list List all tests under the tests directory" 45 | @echo " test/watch Automatically run tests every second" 46 | @echo " docker/alpine Run into a Docker Linux/Alpine:latest image" 47 | @echo " pre_commit/install Install the pre-commit hook" 48 | @echo " pre_commit/run Function that will be called when the pre-commit hook runs" 49 | @echo " sa Run shellcheck static analysis tool" 50 | @echo " lint Run editorconfig linter tool" 51 | 52 | SRC_SCRIPTS_DIR=src 53 | TEST_SCRIPTS_DIR=tests 54 | EXAMPLE_TEST_SCRIPTS=./example/logic_test.sh 55 | PRE_COMMIT_SCRIPTS_FILE=./bin/pre-commit 56 | 57 | TEST_SCRIPTS = $(wildcard $(TEST_SCRIPTS_DIR)/*/*[tT]est.sh) 58 | 59 | test/list: 60 | @echo "Test scripts found:" 61 | @echo $(TEST_SCRIPTS) | tr ' ' '\n' 62 | 63 | test: $(TEST_SCRIPTS) 64 | @bash ./bashunit $(TEST_SCRIPTS) 65 | 66 | test/watch: $(TEST_SCRIPTS) 67 | @bash ./bashunit $(TEST_SCRIPTS) 68 | @fswatch -m poll_monitor -or $(SRC_SCRIPTS_DIR) $(TEST_SCRIPTS_DIR) .env Makefile | xargs -n1 bash ./bashunit $(TEST_SCRIPTS) 69 | 70 | docker/alpine: 71 | @docker run --rm -it -v "$(shell pwd)":/project -w /project alpine:latest \ 72 | sh -c "apk add bash make shellcheck git && bash" 73 | 74 | docker/ubuntu: 75 | @docker run --rm -it -v "$(shell pwd)":/project -w /project ubuntu:latest \ 76 | sh -c "apt update && apt install -y bash make shellcheck git && bash" 77 | 78 | pre_commit/install: 79 | @echo "Installing pre-commit hook" 80 | cp $(PRE_COMMIT_SCRIPTS_FILE) $(GIT_DIR)/hooks/ 81 | 82 | pre_commit/run: test sa lint 83 | 84 | sa: 85 | ifndef STATIC_ANALYSIS_CHECKER 86 | @printf "\e[1m\e[31m%s\e[0m\n" "Shellcheck not installed: Static analysis not performed!" && exit 1 87 | else 88 | @find . -name "*.sh" -not -path "./local/*" -exec shellcheck -xC {} \; && printf "\e[1m\e[32m%s\e[0m\n" "ShellCheck: OK!" 89 | endif 90 | 91 | lint: 92 | ifndef LINTER_CHECKER 93 | @printf "\e[1m\e[31m%s\e[0m\n" "Editorconfig not installed: Lint not performed!" && exit 1 94 | else 95 | @editorconfig-checker && printf "\e[1m\e[32m%s\e[0m\n" "editorconfig-check: OK!" 96 | endif 97 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # shellcheck disable=SC2155 3 | # shellcheck disable=SC2164 4 | 5 | function is_git_installed() { 6 | command -v git > /dev/null 2>&1 7 | } 8 | 9 | function get_latest_tag() { 10 | local repository_url=$1 11 | 12 | git ls-remote --tags "$repository_url" | 13 | awk '{print $2}' | 14 | sed 's|^refs/tags/||' | 15 | sort -Vr | 16 | head -n 1 17 | } 18 | 19 | function build_and_install_beta() { 20 | echo "> Downloading non-stable version: 'beta'" 21 | 22 | if ! is_git_installed; then 23 | echo "Error: git is not installed." >&2 24 | exit 1 25 | fi 26 | 27 | git clone --depth 1 --no-tags "$BASHUNIT_GIT_REPO" temp_bashunit 2>/dev/null 28 | cd temp_bashunit 29 | ./build.sh bin >/dev/null 30 | local latest_commit=$(git rev-parse --short=7 HEAD) 31 | # shellcheck disable=SC2103 32 | cd .. 33 | 34 | local beta_version=$(printf "(non-stable) beta after %s [%s] 🐍 #%s" \ 35 | "$LATEST_BASHUNIT_VERSION" \ 36 | "$(date +'%Y-%m-%d')" \ 37 | "$latest_commit") 38 | 39 | sed -i -e 's/BASHUNIT_VERSION=".*"/BASHUNIT_VERSION="'"$beta_version"'"/g' temp_bashunit/bin/bashunit 40 | cp temp_bashunit/bin/bashunit ./ 41 | rm -rf temp_bashunit 42 | } 43 | 44 | function install() { 45 | if [[ $VERSION != 'latest' ]]; then 46 | TAG="$VERSION" 47 | echo "> Downloading a concrete version: '$TAG'" 48 | else 49 | echo "> Downloading the latest version: '$TAG'" 50 | fi 51 | 52 | if command -v curl > /dev/null 2>&1; then 53 | curl -L -O -J "$BASHUNIT_GIT_REPO/releases/download/$TAG/bashunit" 2>/dev/null 54 | elif command -v wget > /dev/null 2>&1; then 55 | wget "$BASHUNIT_GIT_REPO/releases/download/$TAG/bashunit" 2>/dev/null 56 | else 57 | echo "Cannot download bashunit: curl or wget not found." 58 | fi 59 | chmod u+x "bashunit" 60 | } 61 | 62 | ######################### 63 | ######### MAIN ########## 64 | ######################### 65 | 66 | # Defaults 67 | DIR="lib" 68 | VERSION="latest" 69 | 70 | function is_version() { 71 | [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "$1" == "latest" || "$1" == "beta" ]] 72 | } 73 | 74 | # Parse arguments flexibly 75 | if [[ $# -eq 1 ]]; then 76 | if is_version "$1"; then 77 | VERSION="$1" 78 | else 79 | DIR="$1" 80 | fi 81 | elif [[ $# -eq 2 ]]; then 82 | if is_version "$1"; then 83 | VERSION="$1" 84 | DIR="$2" 85 | elif is_version "$2"; then 86 | DIR="$1" 87 | VERSION="$2" 88 | else 89 | echo "Invalid arguments. Expected version or directory." >&2 90 | exit 1 91 | fi 92 | fi 93 | 94 | BASHUNIT_GIT_REPO="https://github.com/TypedDevs/bashunit" 95 | if is_git_installed; then 96 | LATEST_BASHUNIT_VERSION="$(get_latest_tag "$BASHUNIT_GIT_REPO")" 97 | else 98 | LATEST_BASHUNIT_VERSION="0.29.0" 99 | fi 100 | TAG="$LATEST_BASHUNIT_VERSION" 101 | 102 | cd "$(dirname "$0")" 103 | rm -f "$DIR"/bashunit 104 | [ -d "$DIR" ] || mkdir "$DIR" 105 | cd "$DIR" 106 | 107 | if [[ $VERSION == 'beta' ]]; then 108 | build_and_install_beta 109 | else 110 | install 111 | fi 112 | 113 | echo "> bashunit has been installed in the '$DIR' folder" 114 | -------------------------------------------------------------------------------- /src/assert_snapshot.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # shellcheck disable=SC2155 3 | 4 | function assert_match_snapshot() { 5 | local actual=$(echo -n "$1" | tr -d '\r') 6 | local test_fn 7 | test_fn="$(bashunit::helper::find_test_function_name)" 8 | local snapshot_file=$(bashunit::snapshot::resolve_file "${2:-}" "$test_fn") 9 | 10 | if [[ ! -f "$snapshot_file" ]]; then 11 | bashunit::snapshot::initialize "$snapshot_file" "$actual" 12 | return 13 | fi 14 | 15 | bashunit::snapshot::compare "$actual" "$snapshot_file" "$test_fn" 16 | } 17 | 18 | function assert_match_snapshot_ignore_colors() { 19 | local actual=$(echo -n "$1" | sed 's/\x1B\[[0-9;]*[mK]//g' | tr -d '\r') 20 | local test_fn 21 | test_fn="$(bashunit::helper::find_test_function_name)" 22 | local snapshot_file=$(bashunit::snapshot::resolve_file "${2:-}" "$test_fn") 23 | 24 | if [[ ! -f "$snapshot_file" ]]; then 25 | bashunit::snapshot::initialize "$snapshot_file" "$actual" 26 | return 27 | fi 28 | 29 | bashunit::snapshot::compare "$actual" "$snapshot_file" "$test_fn" 30 | } 31 | 32 | function bashunit::snapshot::match_with_placeholder() { 33 | local actual="$1" 34 | local snapshot="$2" 35 | local placeholder="${BASHUNIT_SNAPSHOT_PLACEHOLDER:-::ignore::}" 36 | local token="__BASHUNIT_IGNORE__" 37 | 38 | local sanitized="${snapshot//$placeholder/$token}" 39 | local escaped=$(printf '%s' "$sanitized" | sed -e 's/[.[\\^$*+?{}()|]/\\&/g') 40 | local regex="^${escaped//$token/(.|\\n)*}$" 41 | 42 | if command -v perl >/dev/null 2>&1; then 43 | echo "$actual" | REGEX="$regex" perl -0 -e ' 44 | my $r = $ENV{REGEX}; 45 | my $input = join("", ); 46 | exit($input =~ /$r/s ? 0 : 1); 47 | ' && return 0 || return 1 48 | else 49 | local fallback=$(printf '%s' "$snapshot" | sed -e "s|$placeholder|.*|g" -e 's/[][\.^$*+?{}|()]/\\&/g') 50 | fallback="^${fallback}$" 51 | echo "$actual" | grep -Eq "$fallback" && return 0 || return 1 52 | fi 53 | } 54 | 55 | function bashunit::snapshot::resolve_file() { 56 | local file_hint="$1" 57 | local func_name="$2" 58 | 59 | if [[ -n "$file_hint" ]]; then 60 | echo "$file_hint" 61 | else 62 | local dir="./$(dirname "${BASH_SOURCE[2]}")/snapshots" 63 | local test_file="$(bashunit::helper::normalize_variable_name "$(basename "${BASH_SOURCE[2]}")")" 64 | local name="$(bashunit::helper::normalize_variable_name "$func_name").snapshot" 65 | echo "${dir}/${test_file}.${name}" 66 | fi 67 | } 68 | 69 | function bashunit::snapshot::initialize() { 70 | local path="$1" 71 | local content="$2" 72 | mkdir -p "$(dirname "$path")" 73 | echo "$content" > "$path" 74 | bashunit::state::add_assertions_snapshot 75 | } 76 | 77 | function bashunit::snapshot::compare() { 78 | local actual="$1" 79 | local snapshot_path="$2" 80 | local func_name="$3" 81 | 82 | local snapshot 83 | snapshot=$(tr -d '\r' < "$snapshot_path") 84 | 85 | if ! bashunit::snapshot::match_with_placeholder "$actual" "$snapshot"; then 86 | local label=$(bashunit::helper::normalize_test_function_name "$func_name") 87 | bashunit::state::add_assertions_failed 88 | bashunit::console_results::print_failed_snapshot_test "$label" "$snapshot_path" "$actual" 89 | return 1 90 | fi 91 | 92 | bashunit::state::add_assertions_passed 93 | } 94 | -------------------------------------------------------------------------------- /src/globals.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # This file provides a set of global functions to developers. 5 | 6 | function bashunit::current_dir() { 7 | dirname "${BASH_SOURCE[1]}" 8 | } 9 | 10 | function bashunit::current_filename() { 11 | basename "${BASH_SOURCE[1]}" 12 | } 13 | 14 | function bashunit::caller_filename() { 15 | dirname "${BASH_SOURCE[2]}" 16 | } 17 | 18 | function bashunit::caller_line() { 19 | echo "${BASH_LINENO[1]}" 20 | } 21 | 22 | function bashunit::current_timestamp() { 23 | date +"%Y-%m-%d %H:%M:%S" 24 | } 25 | 26 | function bashunit::is_command_available() { 27 | command -v "$1" >/dev/null 2>&1 28 | } 29 | 30 | function bashunit::random_str() { 31 | local length=${1:-6} 32 | local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 33 | local str='' 34 | for (( i=0; i> "$BASHUNIT_DEV_LOG" 99 | } 100 | 101 | function bashunit::internal_log() { 102 | if ! bashunit::env::is_dev_mode_enabled || ! bashunit::env::is_internal_log_enabled; then 103 | return 104 | fi 105 | 106 | echo "$(bashunit::current_timestamp) [INTERNAL]: $* #${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >> "$BASHUNIT_DEV_LOG" 107 | } 108 | 109 | function bashunit::print_line() { 110 | local length="${1:-70}" # Default to 70 if not passed 111 | local char="${2:--}" # Default to '-' if not passed 112 | printf '%*s\n' "$length" '' | tr ' ' "$char" 113 | } 114 | 115 | function bashunit::data_set() { 116 | local arg 117 | local first=true 118 | 119 | for arg in "$@"; do 120 | if [ "$first" = true ]; then 121 | printf '%q' "$arg" 122 | first=false 123 | else 124 | printf ' %q' "$arg" 125 | fi 126 | done 127 | printf ' %q\n' "" 128 | } 129 | -------------------------------------------------------------------------------- /src/parallel.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function bashunit::parallel::aggregate_test_results() { 4 | local temp_dir_parallel_test_suite=$1 5 | 6 | bashunit::internal_log "aggregate_test_results" "dir:$temp_dir_parallel_test_suite" 7 | 8 | local total_failed=0 9 | local total_passed=0 10 | local total_skipped=0 11 | local total_incomplete=0 12 | local total_snapshot=0 13 | 14 | for script_dir in "$temp_dir_parallel_test_suite"/*; do 15 | shopt -s nullglob 16 | local result_files=("$script_dir"/*.result) 17 | shopt -u nullglob 18 | 19 | if [ ${#result_files[@]} -eq 0 ]; then 20 | printf "%sNo tests found%s" "$_BASHUNIT_COLOR_SKIPPED" "$_BASHUNIT_COLOR_DEFAULT" 21 | continue 22 | fi 23 | 24 | for result_file in "${result_files[@]}"; do 25 | local result_line 26 | result_line=$(tail -n 1 < "$result_file") 27 | 28 | local failed="${result_line##*##ASSERTIONS_FAILED=}" 29 | failed="${failed%%##*}"; failed=${failed:-0} 30 | 31 | local passed="${result_line##*##ASSERTIONS_PASSED=}" 32 | passed="${passed%%##*}"; passed=${passed:-0} 33 | 34 | local skipped="${result_line##*##ASSERTIONS_SKIPPED=}" 35 | skipped="${skipped%%##*}"; skipped=${skipped:-0} 36 | 37 | local incomplete="${result_line##*##ASSERTIONS_INCOMPLETE=}" 38 | incomplete="${incomplete%%##*}"; incomplete=${incomplete:-0} 39 | 40 | local snapshot="${result_line##*##ASSERTIONS_SNAPSHOT=}" 41 | snapshot="${snapshot%%##*}"; snapshot=${snapshot:-0} 42 | 43 | local exit_code="${result_line##*##TEST_EXIT_CODE=}" 44 | exit_code="${exit_code%%##*}"; exit_code=${exit_code:-0} 45 | 46 | # Add to the total counts 47 | total_failed=$((total_failed + failed)) 48 | total_passed=$((total_passed + passed)) 49 | total_skipped=$((total_skipped + skipped)) 50 | total_incomplete=$((total_incomplete + incomplete)) 51 | total_snapshot=$((total_snapshot + snapshot)) 52 | 53 | if [ "${failed:-0}" -gt 0 ]; then 54 | bashunit::state::add_tests_failed 55 | continue 56 | fi 57 | 58 | if [ "${exit_code:-0}" -ne 0 ]; then 59 | bashunit::state::add_tests_failed 60 | continue 61 | fi 62 | 63 | if [ "${snapshot:-0}" -gt 0 ]; then 64 | bashunit::state::add_tests_snapshot 65 | continue 66 | fi 67 | 68 | if [ "${incomplete:-0}" -gt 0 ]; then 69 | bashunit::state::add_tests_incomplete 70 | continue 71 | fi 72 | 73 | if [ "${skipped:-0}" -gt 0 ]; then 74 | bashunit::state::add_tests_skipped 75 | continue 76 | fi 77 | 78 | bashunit::state::add_tests_passed 79 | done 80 | done 81 | 82 | export _BASHUNIT_ASSERTIONS_FAILED=$total_failed 83 | export _BASHUNIT_ASSERTIONS_PASSED=$total_passed 84 | export _BASHUNIT_ASSERTIONS_SKIPPED=$total_skipped 85 | export _BASHUNIT_ASSERTIONS_INCOMPLETE=$total_incomplete 86 | export _BASHUNIT_ASSERTIONS_SNAPSHOT=$total_snapshot 87 | 88 | bashunit::internal_log "aggregate_totals" \ 89 | "failed:$total_failed" \ 90 | "passed:$total_passed" \ 91 | "skipped:$total_skipped" \ 92 | "incomplete:$total_incomplete" \ 93 | "snapshot:$total_snapshot" 94 | } 95 | 96 | function bashunit::parallel::mark_stop_on_failure() { 97 | touch "$TEMP_FILE_PARALLEL_STOP_ON_FAILURE" 98 | } 99 | 100 | function bashunit::parallel::must_stop_on_failure() { 101 | [[ -f "$TEMP_FILE_PARALLEL_STOP_ON_FAILURE" ]] 102 | } 103 | 104 | function bashunit::parallel::cleanup() { 105 | # shellcheck disable=SC2153 106 | rm -rf "$TEMP_DIR_PARALLEL_TEST_SUITE" 107 | } 108 | 109 | function bashunit::parallel::init() { 110 | bashunit::parallel::cleanup 111 | mkdir -p "$TEMP_DIR_PARALLEL_TEST_SUITE" 112 | } 113 | 114 | function bashunit::parallel::is_enabled() { 115 | bashunit::internal_log "bashunit::parallel::is_enabled" \ 116 | "requested:$BASHUNIT_PARALLEL_RUN" "os:${_BASHUNIT_OS:-Unknown}" 117 | 118 | if bashunit::env::is_parallel_run_enabled && \ 119 | (bashunit::check_os::is_macos || bashunit::check_os::is_ubuntu || bashunit::check_os::is_windows); then 120 | return 0 121 | fi 122 | return 1 123 | } 124 | -------------------------------------------------------------------------------- /bashunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | declare -r BASHUNIT_MIN_BASH_VERSION="3.2" 5 | 6 | function _check_bash_version() { 7 | local current_version 8 | if [[ -n ${BASHUNIT_TEST_BASH_VERSION:-} ]]; then 9 | # Checks if BASHUNIT_TEST_BASH_VERSION is set (typically for testing purposes) 10 | current_version="${BASHUNIT_TEST_BASH_VERSION}" 11 | elif [[ -n ${BASH_VERSINFO+set} ]]; then 12 | # Checks if the special Bash array BASH_VERSINFO exists. This array is only defined in Bash. 13 | current_version="${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" 14 | else 15 | # If not in Bash (e.g., running from Zsh). The pipeline extracts just the major.minor version (e.g., 3.2). 16 | current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)" 17 | fi 18 | 19 | local major minor 20 | IFS=. read -r major minor _ <<< "$current_version" 21 | 22 | if (( major < 3 )) || { (( major == 3 )) && (( minor < 2 )); }; then 23 | printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 24 | exit 1 25 | fi 26 | } 27 | 28 | _check_bash_version 29 | 30 | # shellcheck disable=SC2034 31 | declare -r BASHUNIT_VERSION="0.29.0" 32 | 33 | # shellcheck disable=SC2155 34 | declare -r BASHUNIT_ROOT_DIR="$(dirname "${BASH_SOURCE[0]}")" 35 | export BASHUNIT_ROOT_DIR 36 | 37 | # Capture working directory at startup (before any test changes it) 38 | declare -r BASHUNIT_WORKING_DIR="$PWD" 39 | export BASHUNIT_WORKING_DIR 40 | 41 | # Early scan for flags that must be set before loading env.sh 42 | for arg in "$@"; do 43 | case "$arg" in 44 | --skip-env-file) 45 | export BASHUNIT_SKIP_ENV_FILE=true 46 | ;; 47 | -l|--login) 48 | export BASHUNIT_LOGIN_SHELL=true 49 | ;; 50 | --no-color) 51 | # shellcheck disable=SC2034 52 | BASHUNIT_NO_COLOR=true 53 | ;; 54 | esac 55 | done 56 | 57 | source "$BASHUNIT_ROOT_DIR/src/dev/debug.sh" 58 | source "$BASHUNIT_ROOT_DIR/src/check_os.sh" 59 | source "$BASHUNIT_ROOT_DIR/src/str.sh" 60 | source "$BASHUNIT_ROOT_DIR/src/globals.sh" 61 | source "$BASHUNIT_ROOT_DIR/src/dependencies.sh" 62 | source "$BASHUNIT_ROOT_DIR/src/io.sh" 63 | source "$BASHUNIT_ROOT_DIR/src/math.sh" 64 | source "$BASHUNIT_ROOT_DIR/src/parallel.sh" 65 | source "$BASHUNIT_ROOT_DIR/src/env.sh" 66 | source "$BASHUNIT_ROOT_DIR/src/clock.sh" 67 | source "$BASHUNIT_ROOT_DIR/src/state.sh" 68 | source "$BASHUNIT_ROOT_DIR/src/colors.sh" 69 | source "$BASHUNIT_ROOT_DIR/src/console_header.sh" 70 | source "$BASHUNIT_ROOT_DIR/src/console_results.sh" 71 | source "$BASHUNIT_ROOT_DIR/src/helpers.sh" 72 | source "$BASHUNIT_ROOT_DIR/src/test_title.sh" 73 | source "$BASHUNIT_ROOT_DIR/src/upgrade.sh" 74 | source "$BASHUNIT_ROOT_DIR/src/assertions.sh" 75 | source "$BASHUNIT_ROOT_DIR/src/doc.sh" 76 | source "$BASHUNIT_ROOT_DIR/src/reports.sh" 77 | source "$BASHUNIT_ROOT_DIR/src/runner.sh" 78 | source "$BASHUNIT_ROOT_DIR/src/bashunit.sh" 79 | source "$BASHUNIT_ROOT_DIR/src/init.sh" 80 | source "$BASHUNIT_ROOT_DIR/src/learn.sh" 81 | source "$BASHUNIT_ROOT_DIR/src/main.sh" 82 | 83 | bashunit::check_os::init 84 | bashunit::clock::init 85 | 86 | # Subcommand detection 87 | _SUBCOMMAND="" 88 | 89 | case "${1:-}" in 90 | test|bench|doc|init|learn|upgrade|assert) 91 | _SUBCOMMAND="$1" 92 | shift 93 | ;; 94 | -v|--version) 95 | bashunit::console_header::print_version 96 | exit 0 97 | ;; 98 | -h|--help) 99 | bashunit::console_header::print_help 100 | exit 0 101 | ;; 102 | -*) 103 | # Flag without subcommand → assume "test" 104 | _SUBCOMMAND="test" 105 | ;; 106 | "") 107 | # No arguments → assume "test" (uses BASHUNIT_DEFAULT_PATH) 108 | _SUBCOMMAND="test" 109 | ;; 110 | *) 111 | # Path argument → assume "test" 112 | _SUBCOMMAND="test" 113 | ;; 114 | esac 115 | 116 | # Route to subcommand handler 117 | case "$_SUBCOMMAND" in 118 | test) bashunit::main::cmd_test "$@" ;; 119 | bench) bashunit::main::cmd_bench "$@" ;; 120 | doc) bashunit::main::cmd_doc "$@" ;; 121 | init) bashunit::main::cmd_init "$@" ;; 122 | learn) bashunit::main::cmd_learn "$@" ;; 123 | upgrade) bashunit::main::cmd_upgrade "$@" ;; 124 | assert) bashunit::main::cmd_assert "$@" ;; 125 | esac 126 | -------------------------------------------------------------------------------- /src/clock.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | _BASHUNIT_CLOCK_NOW_IMPL="" 4 | 5 | function bashunit::clock::_choose_impl() { 6 | local shell_time 7 | local attempts=() 8 | 9 | # 1. Try Perl with Time::HiRes 10 | attempts+=("Perl") 11 | if bashunit::dependencies::has_perl && perl -MTime::HiRes -e "" &>/dev/null; then 12 | _BASHUNIT_CLOCK_NOW_IMPL="perl" 13 | return 0 14 | fi 15 | 16 | # 2. Try Python 3 with time module 17 | attempts+=("Python") 18 | if bashunit::dependencies::has_python; then 19 | _BASHUNIT_CLOCK_NOW_IMPL="python" 20 | return 0 21 | fi 22 | 23 | # 3. Try Node.js 24 | attempts+=("Node") 25 | if bashunit::dependencies::has_node; then 26 | _BASHUNIT_CLOCK_NOW_IMPL="node" 27 | return 0 28 | fi 29 | # 4. Windows fallback with PowerShell 30 | attempts+=("PowerShell") 31 | if bashunit::check_os::is_windows && bashunit::dependencies::has_powershell; then 32 | _BASHUNIT_CLOCK_NOW_IMPL="powershell" 33 | return 0 34 | fi 35 | 36 | # 5. Unix fallback using `date +%s%N` (if not macOS or Alpine) 37 | attempts+=("date") 38 | if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then 39 | local result 40 | result=$(date +%s%N 2>/dev/null) 41 | if [[ "$result" != *N && "$result" =~ ^[0-9]+$ ]]; then 42 | _BASHUNIT_CLOCK_NOW_IMPL="date" 43 | return 0 44 | fi 45 | fi 46 | 47 | # 6. Try using native shell EPOCHREALTIME (if available) 48 | attempts+=("EPOCHREALTIME") 49 | if shell_time="$(bashunit::clock::shell_time)"; then 50 | _BASHUNIT_CLOCK_NOW_IMPL="shell" 51 | return 0 52 | fi 53 | 54 | # 7. Very last fallback: seconds resolution only 55 | attempts[${#attempts[@]}]="date-seconds" 56 | if date +%s &>/dev/null; then 57 | _BASHUNIT_CLOCK_NOW_IMPL="date-seconds" 58 | return 0 59 | fi 60 | 61 | # 8. All methods failed 62 | printf "bashunit::clock::now implementations tried: %s\n" "${attempts[*]}" >&2 63 | echo "" 64 | return 1 65 | } 66 | 67 | function bashunit::clock::now() { 68 | if [[ -z "$_BASHUNIT_CLOCK_NOW_IMPL" ]]; then 69 | bashunit::clock::_choose_impl || return 1 70 | fi 71 | 72 | case "$_BASHUNIT_CLOCK_NOW_IMPL" in 73 | perl) 74 | perl -MTime::HiRes -e 'printf("%.0f\n", Time::HiRes::time() * 1000000000)' 75 | ;; 76 | python) 77 | python - <<'EOF' 78 | import time, sys 79 | sys.stdout.write(str(int(time.time() * 1000000000))) 80 | EOF 81 | ;; 82 | node) 83 | node -e 'process.stdout.write((BigInt(Date.now()) * 1000000n).toString())' 84 | ;; 85 | powershell) 86 | powershell -Command "\ 87 | \$unixEpoch = [DateTime]'1970-01-01 00:00:00';\ 88 | \$now = [DateTime]::UtcNow;\ 89 | \$ticksSinceEpoch = (\$now - \$unixEpoch).Ticks;\ 90 | \$nanosecondsSinceEpoch = \$ticksSinceEpoch * 100;\ 91 | Write-Output \$nanosecondsSinceEpoch\ 92 | " 93 | ;; 94 | date) 95 | date +%s%N 96 | ;; 97 | date-seconds) 98 | local seconds 99 | seconds=$(date +%s) 100 | bashunit::math::calculate "$seconds * 1000000000" 101 | ;; 102 | shell) 103 | # shellcheck disable=SC2155 104 | local shell_time="$(bashunit::clock::shell_time)" 105 | local seconds="${shell_time%%.*}" 106 | local microseconds="${shell_time#*.}" 107 | bashunit::math::calculate "($seconds * 1000000000) + ($microseconds * 1000)" 108 | ;; 109 | *) 110 | bashunit::clock::_choose_impl || return 1 111 | bashunit::clock::now 112 | ;; 113 | esac 114 | } 115 | 116 | function bashunit::clock::shell_time() { 117 | # Get time directly from the shell variable EPOCHREALTIME (Bash 5+) 118 | [[ -n ${EPOCHREALTIME+x} && -n "$EPOCHREALTIME" ]] && LC_ALL=C echo "$EPOCHREALTIME" 119 | } 120 | 121 | function bashunit::clock::total_runtime_in_milliseconds() { 122 | local end_time 123 | end_time=$(bashunit::clock::now) 124 | if [[ -n $end_time ]]; then 125 | bashunit::math::calculate "($end_time - $_BASHUNIT_START_TIME) / 1000000" 126 | else 127 | echo "" 128 | fi 129 | } 130 | 131 | function bashunit::clock::total_runtime_in_nanoseconds() { 132 | local end_time 133 | end_time=$(bashunit::clock::now) 134 | if [[ -n $end_time ]]; then 135 | bashunit::math::calculate "$end_time - $_BASHUNIT_START_TIME" 136 | else 137 | echo "" 138 | fi 139 | } 140 | 141 | function bashunit::clock::init() { 142 | _BASHUNIT_START_TIME=$(bashunit::clock::now) 143 | } 144 | -------------------------------------------------------------------------------- /adrs/adr-005-copilot-instruction-or-spec-kit.md: -------------------------------------------------------------------------------- 1 | # Choose Copilot Custom Instructions over Spec Kit for bashunit 2 | 3 | * Status: proposed 4 | * Deciders: @khru 5 | * Date: 2025-09-17 6 | 7 | Technical Story: We need a lightweight, high leverage AI assist that improves contribution quality and speed without adding process overhead to a small Bash library. 8 | 9 | ## Context and Problem Statement 10 | 11 | bashunit is a compact open source Bash testing library. We want AI assistance that nudges contributors toward consistent style, portability, and test structure. Two candidates exist: GitHub Copilot Custom Instructions and GitHub Spec Kit. Which approach best fits bashunit’s size and workflow? 12 | 13 | ## Decision Drivers 14 | 15 | * Keep contributor workflow simple and fast 16 | * Enforce consistent Bash and test conventions with minimal tooling 17 | * Reduce review friction and style nitpicks 18 | * Avoid heavy bootstrapping or new runtime dependencies 19 | * Leave room to explore structured specs later if needed 20 | 21 | ## Considered Options 22 | 23 | * Copilot Custom Instructions at repository scope 24 | * Spec Kit as the core workflow 25 | * Hybrid approach: Copilot now, Spec Kit only for large initiatives 26 | 27 | ## Decision Outcome 28 | 29 | Chosen option: "Copilot Custom Instructions at repository scope", because it delivers immediate guidance in Chat, coding agent, and code review with near zero overhead, matches bashunit’s scale, and supports path specific rules for Bash and docs. Spec Kit is valuable for multi phase feature work but introduces extra setup and process that bashunit does not currently need. 30 | 31 | ### Positive Consequences 32 | 33 | * Faster, more consistent PRs with fewer style and portability fixes 34 | * Guidance lives in the repo, visible and versioned with code 35 | * Path specific rules help tailor guidance for `lib/`, `tests/`, and docs 36 | 37 | ### Negative Consequences 38 | 39 | * Possible conflicts with personal or organization instructions, require clear precedence awareness 40 | * Preview features in Copilot instructions can change, we must monitor docs 41 | 42 | ## Pros and Cons of the Options 43 | 44 | ### Copilot Custom Instructions at repository scope 45 | 46 | * Good, because setup is trivial, just add `.github/copilot-instructions.md` and optional `.github/instructions/*.instructions.md` 47 | * Good, because guidance is applied in Chat, coding agent, and code review where contributors already work 48 | * Good, because path based `applyTo` rules let us enforce Bash portability and test naming in specific folders 49 | * Bad, because it is not a full specification or planning framework if we ever need complex multi step delivery 50 | 51 | ### Spec Kit as the core workflow 52 | 53 | * Good, because it structures specs, plans, and tasks for complex features and parallel exploration 54 | * Good, because it can coordinate with multiple agents and make specifications executable 55 | * Bad, because it adds Python and `uv` dependencies plus a new CLI and multi step process 56 | * Bad, because that overhead is unnecessary for a small Bash library with simple APIs and docs 57 | 58 | ### Hybrid approach 59 | 60 | * Good, because we keep the repo light while reserving Spec Kit for large, time boxed initiatives 61 | * Good, because it lets us validate Spec Kit on a real feature without changing the whole workflow 62 | * Bad, because it introduces two patterns to maintain if used frequently 63 | * Bad, because contributors may be unsure when to use which process without clear guidance 64 | 65 | ## Links 66 | 67 | * Spec Kit repository: [https://github.com/github/spec-kit](https://github.com/github/spec-kit) 68 | * Spec Kit blog overview: [https://github.blog/ai-and-ml/generative-ai/spec-driven-development-with-ai-get-started-with-a-new-open-source-toolkit/](https://github.blog/ai-and-ml/generative-ai/spec-driven-development-with-ai-get-started-with-a-new-open-source-toolkit/) 69 | * Copilot repository instructions: [https://docs.github.com/es/copilot/how-tos/configure-custom-instructions/add-repository-instructions](https://docs.github.com/es/copilot/how-tos/configure-custom-instructions/add-repository-instructions) 70 | * Copilot personal instructions: [https://docs.github.com/es/copilot/how-tos/configure-custom-instructions/add-personal-instructions](https://docs.github.com/es/copilot/how-tos/configure-custom-instructions/add-personal-instructions) 71 | * Copilot organization instructions: [https://docs.github.com/es/copilot/how-tos/configure-custom-instructions/add-organization-instructions](https://docs.github.com/es/copilot/how-tos/configure-custom-instructions/add-organization-instructions) 72 | -------------------------------------------------------------------------------- /src/benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | _BASHUNIT_BENCH_NAMES=() 4 | _BASHUNIT_BENCH_REVS=() 5 | _BASHUNIT_BENCH_ITS=() 6 | _BASHUNIT_BENCH_AVERAGES=() 7 | _BASHUNIT_BENCH_MAX_MILLIS=() 8 | 9 | function bashunit::benchmark::parse_annotations() { 10 | local fn_name=$1 11 | local script=$2 12 | local revs=1 13 | local its=1 14 | local max_ms="" 15 | 16 | local annotation 17 | annotation=$(awk "/function[[:space:]]+${fn_name}[[:space:]]*\(/ {print prev; exit} {prev=\$0}" "$script") 18 | 19 | if [[ $annotation =~ @revs=([0-9]+) ]]; then 20 | revs="${BASH_REMATCH[1]}" 21 | elif [[ $annotation =~ @revolutions=([0-9]+) ]]; then 22 | revs="${BASH_REMATCH[1]}" 23 | fi 24 | 25 | if [[ $annotation =~ @its=([0-9]+) ]]; then 26 | its="${BASH_REMATCH[1]}" 27 | elif [[ $annotation =~ @iterations=([0-9]+) ]]; then 28 | its="${BASH_REMATCH[1]}" 29 | fi 30 | 31 | if [[ $annotation =~ @max_ms=([0-9.]+) ]]; then 32 | max_ms="${BASH_REMATCH[1]}" 33 | elif [[ $annotation =~ @max_ms=([0-9.]+) ]]; then 34 | max_ms="${BASH_REMATCH[1]}" 35 | fi 36 | 37 | if [[ -n "$max_ms" ]]; then 38 | echo "$revs" "$its" "$max_ms" 39 | else 40 | echo "$revs" "$its" 41 | fi 42 | } 43 | 44 | function bashunit::benchmark::add_result() { 45 | _BASHUNIT_BENCH_NAMES+=("$1") 46 | _BASHUNIT_BENCH_REVS+=("$2") 47 | _BASHUNIT_BENCH_ITS+=("$3") 48 | _BASHUNIT_BENCH_AVERAGES+=("$4") 49 | _BASHUNIT_BENCH_MAX_MILLIS+=("$5") 50 | } 51 | 52 | # shellcheck disable=SC2155 53 | function bashunit::benchmark::run_function() { 54 | local fn_name=$1 55 | local revs=$2 56 | local its=$3 57 | local max_ms=$4 58 | local durations=() 59 | 60 | for ((i=1; i<=its; i++)); do 61 | local start_time=$(bashunit::clock::now) 62 | ( 63 | for ((r=1; r<=revs; r++)); do 64 | "$fn_name" >/dev/null 2>&1 65 | done 66 | ) 67 | local end_time=$(bashunit::clock::now) 68 | local dur_ns=$(bashunit::math::calculate "($end_time - $start_time)") 69 | local dur_ms=$(bashunit::math::calculate "$dur_ns / 1000000") 70 | durations+=("$dur_ms") 71 | 72 | if bashunit::env::is_bench_mode_enabled; then 73 | local label="$(bashunit::helper::normalize_test_function_name "$fn_name")" 74 | local line="$label [$i/$its] ${dur_ms} ms" 75 | bashunit::state::print_line "successful" "$line" 76 | fi 77 | done 78 | 79 | local sum=0 80 | for d in "${durations[@]}"; do 81 | sum=$(bashunit::math::calculate "$sum + $d") 82 | done 83 | local avg=$(bashunit::math::calculate "$sum / ${#durations[@]}") 84 | bashunit::benchmark::add_result "$fn_name" "$revs" "$its" "$avg" "$max_ms" 85 | } 86 | 87 | function bashunit::benchmark::print_results() { 88 | if ! bashunit::env::is_bench_mode_enabled; then 89 | return 90 | fi 91 | 92 | if (( ${#_BASHUNIT_BENCH_NAMES[@]} == 0 )); then 93 | return 94 | fi 95 | 96 | if bashunit::env::is_simple_output_enabled; then 97 | printf "\n" 98 | fi 99 | 100 | printf "\nBenchmark Results (avg ms)\n" 101 | bashunit::print_line 80 "=" 102 | printf "\n" 103 | 104 | local has_threshold=false 105 | for val in "${_BASHUNIT_BENCH_MAX_MILLIS[@]}"; do 106 | if [[ -n "$val" ]]; then 107 | has_threshold=true 108 | break 109 | fi 110 | done 111 | 112 | if $has_threshold; then 113 | printf '%-40s %6s %6s %10s %12s\n' "Name" "Revs" "Its" "Avg(ms)" "Status" 114 | else 115 | printf '%-40s %6s %6s %10s\n' "Name" "Revs" "Its" "Avg(ms)" 116 | fi 117 | 118 | for i in "${!_BASHUNIT_BENCH_NAMES[@]}"; do 119 | local name="${_BASHUNIT_BENCH_NAMES[$i]}" 120 | local revs="${_BASHUNIT_BENCH_REVS[$i]}" 121 | local its="${_BASHUNIT_BENCH_ITS[$i]}" 122 | local avg="${_BASHUNIT_BENCH_AVERAGES[$i]}" 123 | local max_ms="${_BASHUNIT_BENCH_MAX_MILLIS[$i]}" 124 | 125 | if [[ -z "$max_ms" ]]; then 126 | printf '%-40s %6s %6s %10s\n' "$name" "$revs" "$its" "$avg" 127 | continue 128 | fi 129 | 130 | if (( $(echo "$avg <= $max_ms" | bc -l) )); then 131 | local raw="≤ ${max_ms}" 132 | printf -v padded "%14s" "$raw" 133 | printf '%-40s %6s %6s %10s %12s\n' "$name" "$revs" "$its" "$avg" "$padded" 134 | continue 135 | fi 136 | 137 | local raw="> ${max_ms}" 138 | printf -v padded "%12s" "$raw" 139 | printf '%-40s %6s %6s %10s %s%s%s\n' \ 140 | "$name" "$revs" "$its" "$avg" \ 141 | "$_BASHUNIT_COLOR_FAILED" "$padded" "${_BASHUNIT_COLOR_DEFAULT}" 142 | done 143 | 144 | bashunit::console_results::print_execution_time 145 | } 146 | -------------------------------------------------------------------------------- /src/assert_files.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function assert_file_exists() { 4 | bashunit::assert::should_skip && return 0 5 | 6 | local expected="$1" 7 | local test_fn 8 | test_fn="$(bashunit::helper::find_test_function_name)" 9 | local label="${3:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 10 | 11 | if [[ ! -f "$expected" ]]; then 12 | bashunit::assert::mark_failed 13 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to exist but" "do not exist" 14 | return 15 | fi 16 | 17 | bashunit::state::add_assertions_passed 18 | } 19 | 20 | function assert_file_not_exists() { 21 | bashunit::assert::should_skip && return 0 22 | 23 | local expected="$1" 24 | local test_fn 25 | test_fn="$(bashunit::helper::find_test_function_name)" 26 | local label="${3:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 27 | 28 | if [[ -f "$expected" ]]; then 29 | bashunit::assert::mark_failed 30 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to not exist but" "the file exists" 31 | return 32 | fi 33 | 34 | bashunit::state::add_assertions_passed 35 | } 36 | 37 | function assert_is_file() { 38 | bashunit::assert::should_skip && return 0 39 | 40 | local expected="$1" 41 | local test_fn 42 | test_fn="$(bashunit::helper::find_test_function_name)" 43 | local label="${3:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 44 | 45 | if [[ ! -f "$expected" ]]; then 46 | bashunit::assert::mark_failed 47 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to be a file" "but is not a file" 48 | return 49 | fi 50 | 51 | bashunit::state::add_assertions_passed 52 | } 53 | 54 | function assert_is_file_empty() { 55 | bashunit::assert::should_skip && return 0 56 | 57 | local expected="$1" 58 | local test_fn 59 | test_fn="$(bashunit::helper::find_test_function_name)" 60 | local label="${3:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 61 | 62 | if [[ -s "$expected" ]]; then 63 | bashunit::assert::mark_failed 64 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to be empty" "but is not empty" 65 | return 66 | fi 67 | 68 | bashunit::state::add_assertions_passed 69 | } 70 | 71 | function assert_files_equals() { 72 | bashunit::assert::should_skip && return 0 73 | 74 | local expected="$1" 75 | local actual="$2" 76 | 77 | if [[ "$(diff -u "$expected" "$actual")" != '' ]] ; then 78 | local test_fn 79 | test_fn="$(bashunit::helper::find_test_function_name)" 80 | local label 81 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 82 | bashunit::assert::mark_failed 83 | 84 | bashunit::console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ 85 | "Diff" "$(diff -u "$expected" "$actual" | sed '1,2d')" 86 | return 87 | fi 88 | 89 | bashunit::state::add_assertions_passed 90 | } 91 | 92 | function assert_files_not_equals() { 93 | bashunit::assert::should_skip && return 0 94 | 95 | local expected="$1" 96 | local actual="$2" 97 | 98 | if [[ "$(diff -u "$expected" "$actual")" == '' ]] ; then 99 | local test_fn 100 | test_fn="$(bashunit::helper::find_test_function_name)" 101 | local label 102 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 103 | bashunit::assert::mark_failed 104 | 105 | bashunit::console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ 106 | "Diff" "Files are equals" 107 | return 108 | fi 109 | 110 | bashunit::state::add_assertions_passed 111 | } 112 | 113 | function assert_file_contains() { 114 | bashunit::assert::should_skip && return 0 115 | 116 | local file="$1" 117 | local string="$2" 118 | 119 | if ! grep -F -q "$string" "$file"; then 120 | local test_fn 121 | test_fn="$(bashunit::helper::find_test_function_name)" 122 | local label 123 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 124 | bashunit::assert::mark_failed 125 | 126 | bashunit::console_results::print_failed_test "${label}" "${file}" "to contain" "${string}" 127 | return 128 | fi 129 | 130 | bashunit::state::add_assertions_passed 131 | } 132 | 133 | function assert_file_not_contains() { 134 | bashunit::assert::should_skip && return 0 135 | 136 | local file="$1" 137 | local string="$2" 138 | 139 | if grep -q "$string" "$file"; then 140 | local test_fn 141 | test_fn="$(bashunit::helper::find_test_function_name)" 142 | local label 143 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 144 | bashunit::assert::mark_failed 145 | 146 | bashunit::console_results::print_failed_test "${label}" "${file}" "to not contain" "${string}" 147 | return 148 | fi 149 | 150 | bashunit::state::add_assertions_passed 151 | } 152 | -------------------------------------------------------------------------------- /src/test_doubles.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | declare -a _BASHUNIT_MOCKED_FUNCTIONS=() 4 | 5 | function bashunit::unmock() { 6 | local command=$1 7 | 8 | for i in "${!_BASHUNIT_MOCKED_FUNCTIONS[@]}"; do 9 | if [[ "${_BASHUNIT_MOCKED_FUNCTIONS[$i]}" == "$command" ]]; then 10 | unset "_BASHUNIT_MOCKED_FUNCTIONS[$i]" 11 | unset -f "$command" 12 | local variable 13 | variable="$(bashunit::helper::normalize_variable_name "$command")" 14 | local times_file_var="${variable}_times_file" 15 | local params_file_var="${variable}_params_file" 16 | [[ -f "${!times_file_var-}" ]] && rm -f "${!times_file_var}" 17 | [[ -f "${!params_file_var-}" ]] && rm -f "${!params_file_var}" 18 | unset "$times_file_var" 19 | unset "$params_file_var" 20 | break 21 | fi 22 | done 23 | } 24 | 25 | function bashunit::mock() { 26 | local command=$1 27 | shift 28 | 29 | if [[ $# -gt 0 ]]; then 30 | eval "function $command() { $* \"\$@\"; }" 31 | else 32 | eval "function $command() { echo \"$($CAT)\" ; }" 33 | fi 34 | 35 | export -f "${command?}" 36 | 37 | _BASHUNIT_MOCKED_FUNCTIONS+=("$command") 38 | } 39 | 40 | function bashunit::spy() { 41 | local command=$1 42 | local variable 43 | variable="$(bashunit::helper::normalize_variable_name "$command")" 44 | 45 | local times_file params_file 46 | local test_id="${BASHUNIT_CURRENT_TEST_ID:-global}" 47 | times_file=$(bashunit::temp_file "${test_id}_${variable}_times") 48 | params_file=$(bashunit::temp_file "${test_id}_${variable}_params") 49 | echo 0 > "$times_file" 50 | : > "$params_file" 51 | export "${variable}_times_file"="$times_file" 52 | export "${variable}_params_file"="$params_file" 53 | 54 | eval "function $command() { 55 | local raw=\"\$*\" 56 | local serialized=\"\" 57 | local arg 58 | for arg in \"\$@\"; do 59 | serialized+=\"\$(printf '%q' \"\$arg\")$'\\x1f'\" 60 | done 61 | serialized=\${serialized%$'\\x1f'} 62 | printf '%s\x1e%s\\n' \"\$raw\" \"\$serialized\" >> '$params_file' 63 | local _c 64 | _c=\$(cat '$times_file' 2>/dev/null || echo 0) 65 | _c=\$((_c+1)) 66 | echo \"\$_c\" > '$times_file' 67 | }" 68 | 69 | export -f "${command?}" 70 | 71 | _BASHUNIT_MOCKED_FUNCTIONS+=("$command") 72 | } 73 | 74 | function assert_have_been_called() { 75 | local command=$1 76 | local variable 77 | variable="$(bashunit::helper::normalize_variable_name "$command")" 78 | local file_var="${variable}_times_file" 79 | local times=0 80 | if [[ -f "${!file_var-}" ]]; then 81 | times=$(cat "${!file_var}" 2>/dev/null || echo 0) 82 | fi 83 | local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" 84 | 85 | if [[ $times -eq 0 ]]; then 86 | bashunit::state::add_assertions_failed 87 | bashunit::console_results::print_failed_test "${label}" "${command}" "to have been called" "once" 88 | return 89 | fi 90 | 91 | bashunit::state::add_assertions_passed 92 | } 93 | 94 | function assert_have_been_called_with() { 95 | local command=$1 96 | shift 97 | 98 | local index="" 99 | if [[ ${!#} =~ ^[0-9]+$ ]]; then 100 | index=${!#} 101 | set -- "${@:1:$#-1}" 102 | fi 103 | 104 | local expected="$*" 105 | 106 | local variable 107 | variable="$(bashunit::helper::normalize_variable_name "$command")" 108 | local file_var="${variable}_params_file" 109 | local line="" 110 | if [[ -f "${!file_var-}" ]]; then 111 | if [[ -n $index ]]; then 112 | line=$(sed -n "${index}p" "${!file_var}" 2>/dev/null || true) 113 | else 114 | line=$(tail -n 1 "${!file_var}" 2>/dev/null || true) 115 | fi 116 | fi 117 | 118 | local raw 119 | IFS=$'\x1e' read -r raw _ <<<"$line" || true 120 | 121 | if [[ "$expected" != "$raw" ]]; then 122 | bashunit::state::add_assertions_failed 123 | bashunit::console_results::print_failed_test "$(bashunit::helper::normalize_test_function_name \ 124 | "${FUNCNAME[1]}")" "$expected" "but got " "$raw" 125 | return 126 | fi 127 | 128 | bashunit::state::add_assertions_passed 129 | } 130 | 131 | function assert_have_been_called_times() { 132 | local expected_count=$1 133 | local command=$2 134 | local variable 135 | variable="$(bashunit::helper::normalize_variable_name "$command")" 136 | local file_var="${variable}_times_file" 137 | local times=0 138 | if [[ -f "${!file_var-}" ]]; then 139 | times=$(cat "${!file_var}" 2>/dev/null || echo 0) 140 | fi 141 | local label="${3:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" 142 | if [[ $times -ne $expected_count ]]; then 143 | bashunit::state::add_assertions_failed 144 | bashunit::console_results::print_failed_test "${label}" "${command}" \ 145 | "to have been called" "${expected_count} times" \ 146 | "actual" "${times} times" 147 | return 148 | fi 149 | 150 | bashunit::state::add_assertions_passed 151 | } 152 | 153 | function assert_not_called() { 154 | local command=$1 155 | local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}" 156 | assert_have_been_called_times 0 "$command" "$label" 157 | } 158 | -------------------------------------------------------------------------------- /src/assert_folders.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function assert_directory_exists() { 4 | bashunit::assert::should_skip && return 0 5 | 6 | local expected="$1" 7 | local test_fn 8 | test_fn="$(bashunit::helper::find_test_function_name)" 9 | local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 10 | 11 | if [[ ! -d "$expected" ]]; then 12 | bashunit::assert::mark_failed 13 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to exist but" "do not exist" 14 | return 15 | fi 16 | 17 | bashunit::state::add_assertions_passed 18 | } 19 | 20 | function assert_directory_not_exists() { 21 | bashunit::assert::should_skip && return 0 22 | 23 | local expected="$1" 24 | local test_fn 25 | test_fn="$(bashunit::helper::find_test_function_name)" 26 | local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 27 | 28 | if [[ -d "$expected" ]]; then 29 | bashunit::assert::mark_failed 30 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to not exist but" "the directory exists" 31 | return 32 | fi 33 | 34 | bashunit::state::add_assertions_passed 35 | } 36 | 37 | function assert_is_directory() { 38 | bashunit::assert::should_skip && return 0 39 | 40 | local expected="$1" 41 | local test_fn 42 | test_fn="$(bashunit::helper::find_test_function_name)" 43 | local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 44 | 45 | if [[ ! -d "$expected" ]]; then 46 | bashunit::assert::mark_failed 47 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to be a directory" "but is not a directory" 48 | return 49 | fi 50 | 51 | bashunit::state::add_assertions_passed 52 | } 53 | 54 | function assert_is_directory_empty() { 55 | bashunit::assert::should_skip && return 0 56 | 57 | local expected="$1" 58 | local test_fn 59 | test_fn="$(bashunit::helper::find_test_function_name)" 60 | local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 61 | 62 | if [[ ! -d "$expected" || -n "$(ls -A "$expected")" ]]; then 63 | bashunit::assert::mark_failed 64 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to be empty" "but is not empty" 65 | return 66 | fi 67 | 68 | bashunit::state::add_assertions_passed 69 | } 70 | 71 | function assert_is_directory_not_empty() { 72 | bashunit::assert::should_skip && return 0 73 | 74 | local expected="$1" 75 | local test_fn 76 | test_fn="$(bashunit::helper::find_test_function_name)" 77 | local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 78 | 79 | if [[ ! -d "$expected" || -z "$(ls -A "$expected")" ]]; then 80 | bashunit::assert::mark_failed 81 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to not be empty" "but is empty" 82 | return 83 | fi 84 | 85 | bashunit::state::add_assertions_passed 86 | } 87 | 88 | function assert_is_directory_readable() { 89 | bashunit::assert::should_skip && return 0 90 | 91 | local expected="$1" 92 | local test_fn 93 | test_fn="$(bashunit::helper::find_test_function_name)" 94 | local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 95 | 96 | if [[ ! -d "$expected" || ! -r "$expected" || ! -x "$expected" ]]; then 97 | bashunit::assert::mark_failed 98 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to be readable" "but is not readable" 99 | return 100 | fi 101 | 102 | bashunit::state::add_assertions_passed 103 | } 104 | 105 | function assert_is_directory_not_readable() { 106 | bashunit::assert::should_skip && return 0 107 | 108 | local expected="$1" 109 | local test_fn 110 | test_fn="$(bashunit::helper::find_test_function_name)" 111 | local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 112 | 113 | if [[ ! -d "$expected" ]] || [[ -r "$expected" && -x "$expected" ]]; then 114 | bashunit::assert::mark_failed 115 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to be not readable" "but is readable" 116 | return 117 | fi 118 | 119 | bashunit::state::add_assertions_passed 120 | } 121 | 122 | function assert_is_directory_writable() { 123 | bashunit::assert::should_skip && return 0 124 | 125 | local expected="$1" 126 | local test_fn 127 | test_fn="$(bashunit::helper::find_test_function_name)" 128 | local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 129 | 130 | if [[ ! -d "$expected" || ! -w "$expected" ]]; then 131 | bashunit::assert::mark_failed 132 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to be writable" "but is not writable" 133 | return 134 | fi 135 | 136 | bashunit::state::add_assertions_passed 137 | } 138 | 139 | function assert_is_directory_not_writable() { 140 | bashunit::assert::should_skip && return 0 141 | 142 | local expected="$1" 143 | local test_fn 144 | test_fn="$(bashunit::helper::find_test_function_name)" 145 | local label="${2:-$(bashunit::helper::normalize_test_function_name "$test_fn")}" 146 | 147 | if [[ ! -d "$expected" || -w "$expected" ]]; then 148 | bashunit::assert::mark_failed 149 | bashunit::console_results::print_failed_test "${label}" "${expected}" "to be not writable" "but is writable" 150 | return 151 | fi 152 | 153 | bashunit::state::add_assertions_passed 154 | } 155 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AGENTS instructions 2 | 3 | **bashunit is a fast, portable Bash testing framework/library.** This guide complements (does not replace) `.github/copilot-instructions.md`. 4 | 5 | ## Prime Directives 6 | 7 | - **TDD by default**: Red → Green → Refactor. Fail **for the right reason**. Implement the **smallest** code to pass. Refactor with all tests green. 8 | - **Task file is mandatory**: Create **`./.tasks/YYYY-MM-DD-slug.md`** before any work; keep it updated (acceptance criteria, **test inventory**, current red bar, timestamped logbook). 9 | - **Definition of Done** must be satisfied to finish. 10 | - **Clarity rule**: If something is ambiguous, ask first; record answers in the task file. 11 | - **ADRs**: Read existing ADRs first; for new decisions, create an ADR using the repo's template and match existing format. 12 | 13 | ## Agent Workflow 14 | 15 | 1) **Before coding** 16 | - Create `./.tasks/YYYY-MM-DD-slug.md` with context, acceptance criteria, test inventory, current red bar, and a timestamped **Logbook**. 17 | - Read `.github/copilot-instructions.md` + relevant ADRs; record links/assumptions in the task file. 18 | - Create a list with all tests needed to cover acceptance criteria 19 | - Add this list to the task file as a **test inventory** (unit, functional, acceptance). 20 | - Prioritize tests by the smallest next step. 21 | - Pick the first test to implement. 22 | - For the testing approach, see the concise overview of the **TDD approach** in `.github/copilot-instructions.md` and keep this file concise. 23 | 24 | 2) **Red** 25 | - Add a test that fails for the intended reason, using **only existing patterns** from `./tests/**`. 26 | 27 | 3) **Green** 28 | - Implement the **minimal** change in `./src/**` to pass; update the Logbook. 29 | 30 | 4) **Refactor** 31 | - Improve code/tests incrementally while keeping all tests green. Update docs/ADR if behavior or decisions change. 32 | - Use `shellcheck -x $(find . -name "*.sh")` and `shfmt -w .` to ensure lint/format compliance. 33 | - Run the test suite with `./bashunit tests/` to ensure everything remains green. 34 | - Run the linting/formatting checks again and ensure compliance. 35 | - Evaluate if any existing tests can be removed or simplified due to refactoring; Or if new tests are needed to cover edge cases discovered during refactoring, add them to the test inventory in the task file. 36 | - Update the task file's Logbook with details of the refactoring process, including any challenges faced and how they were addressed. 37 | - if all the tests are green and the code is clean easy to read and maintain, pick the next test from the inventory and repeat steps 2-4 untill all tests in the inventory are done. and the acceptance criteria are met. 38 | 39 | 5) **Quality Gate (pre-commit)** 40 | - Run repo's real lint/format: `shellcheck -x $(find . -name "*.sh")` and `shfmt -w .` 41 | - Run tests with `./bashunit tests/` (or scoped runs as appropriate). 42 | 43 | 6) **Docs & ADR** 44 | - Update `README`/docs when CLI/assertions/behavior changes. 45 | - Add/update ADRs for significant decisions; link from the task file. 46 | 47 | 7) **Finish (Definition of Done)** 48 | - Linters/formatters **clean**. 49 | - All tests **green for the right reason**. 50 | - Acceptance criteria **met** in the task file. 51 | - Docs/CHANGELOG updated when user-visible changes occur. 52 | 53 | ## bashunit Guardrails 54 | 55 | - Use **only verified** features/patterns proven by `./src/**` and `./tests/**` (assertions, test doubles `mock`/`spy` + `assert_have_been_called*`, data providers, snapshots, skip/todo, globals like `temp_file`/`temp_dir`/`data_set`, lifecycle hooks). 56 | - Prefer spies/mocks for time/OS/tooling; avoid depending on external binaries in unit tests. 57 | - Don't break public API/CLI without semver + docs/CHANGELOG. 58 | - No speculative work: every change starts from a failing test and explicit acceptance criteria. 59 | - Isolation/cleanup: use `temp_file`/`temp_dir`; do not leak state across tests. 60 | 61 | ## Tests & Patterns (usage, not code) 62 | 63 | Examples must mirror **real** patterns from `./tests/**` exactly: 64 | - **Core assertions**: Study `tests/unit/assert_test.sh` for line continuation patterns and failure testing 65 | - **Test doubles**: Study `tests/functional/doubles_test.sh` for mock/spy with fixtures 66 | - **Data providers**: Study `tests/functional/provider_test.sh` for `@data_provider` syntax 67 | - **Lifecycle hooks**: Study `tests/unit/setup_teardown_test.sh` for `set_up_before_script` patterns 68 | - **CLI acceptance**: Study `tests/acceptance/bashunit_test.sh` for snapshot testing 69 | 70 | ## Path-Scoped Guidance 71 | 72 | - `./src/**`: small, portable functions, namespaced; maintain Bash 3.2+ compatibility 73 | - `./tests/**`: behavior-focused tests using official assertions/doubles; avoid networks/unverified tools 74 | - `./.tasks/**`: one file per change (`YYYY-MM-DD-slug.md`); keep AC, test inventory, current red bar, and timestamped Logbook updated 75 | - `./adrs/**`: read first; when adding, use template and match existing ADR style 76 | 77 | ## Prohibitions 78 | 79 | - Don't invent commands or interfaces; extract from repo only. 80 | - Don't change CI/report paths without explicit acceptance criteria and doc/test updates. 81 | - Don't skip the task-file requirement; don't batch unrelated changes in one PR. 82 | 83 | ## Two-Way Sync (mandatory) 84 | 85 | - When **`.github/copilot-instructions.md`** changes, **evaluate** whether the change belongs in `AGENTS.md` and **update `AGENTS.md`** to stay aligned. 86 | - When **`AGENTS.md`** changes, **evaluate** whether the change belongs in `.github/copilot-instructions.md` and **update `.github/copilot-instructions.md`** to stay aligned. 87 | - If a change is intentionally **not** mirrored, record the rationale in the active `./.tasks/YYYY-MM-DD-slug.md`. 88 | 89 | ## PR Checklist 90 | 91 | - ✅ All tests green for the **right reason** 92 | - ✅ Linters/formatters clean 93 | - ✅ Task file updated (AC, test inventory, Logbook, Done timestamp) 94 | - ✅ Docs/README updated; CHANGELOG updated if user-visible 95 | - ✅ ADR added/updated if a decision was made 96 | - ✅ **Two-way sync validated** (`AGENTS.md` ↔ `.github/copilot-instructions.md`) 97 | 98 | For complete details, patterns, and examples, see `.github/copilot-instructions.md`. 99 | -------------------------------------------------------------------------------- /src/reports.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # shellcheck disable=SC2155 3 | 4 | _BASHUNIT_REPORTS_TEST_FILES=() 5 | _BASHUNIT_REPORTS_TEST_NAMES=() 6 | _BASHUNIT_REPORTS_TEST_STATUSES=() 7 | _BASHUNIT_REPORTS_TEST_DURATIONS=() 8 | _BASHUNIT_REPORTS_TEST_ASSERTIONS=() 9 | 10 | function bashunit::reports::add_test_snapshot() { 11 | bashunit::reports::add_test "$1" "$2" "$3" "$4" "snapshot" 12 | } 13 | 14 | function bashunit::reports::add_test_incomplete() { 15 | bashunit::reports::add_test "$1" "$2" "$3" "$4" "incomplete" 16 | } 17 | 18 | function bashunit::reports::add_test_skipped() { 19 | bashunit::reports::add_test "$1" "$2" "$3" "$4" "skipped" 20 | } 21 | 22 | function bashunit::reports::add_test_passed() { 23 | bashunit::reports::add_test "$1" "$2" "$3" "$4" "passed" 24 | } 25 | 26 | function bashunit::reports::add_test_failed() { 27 | bashunit::reports::add_test "$1" "$2" "$3" "$4" "failed" 28 | } 29 | 30 | function bashunit::reports::add_test() { 31 | # Skip tracking when no report output is requested 32 | [[ -n "${BASHUNIT_LOG_JUNIT:-}" || -n "${BASHUNIT_REPORT_HTML:-}" ]] || return 0 33 | 34 | local file="$1" 35 | local test_name="$2" 36 | local duration="$3" 37 | local assertions="$4" 38 | local status="$5" 39 | 40 | _BASHUNIT_REPORTS_TEST_FILES+=("$file") 41 | _BASHUNIT_REPORTS_TEST_NAMES+=("$test_name") 42 | _BASHUNIT_REPORTS_TEST_STATUSES+=("$status") 43 | _BASHUNIT_REPORTS_TEST_ASSERTIONS+=("$assertions") 44 | _BASHUNIT_REPORTS_TEST_DURATIONS+=("$duration") 45 | } 46 | 47 | function bashunit::reports::generate_junit_xml() { 48 | local output_file="$1" 49 | 50 | local test_passed=$(bashunit::state::get_tests_passed) 51 | local tests_skipped=$(bashunit::state::get_tests_skipped) 52 | local tests_incomplete=$(bashunit::state::get_tests_incomplete) 53 | local tests_snapshot=$(bashunit::state::get_tests_snapshot) 54 | local tests_failed=$(bashunit::state::get_tests_failed) 55 | local time=$(bashunit::clock::total_runtime_in_milliseconds) 56 | 57 | { 58 | echo "" 59 | echo "" 60 | echo " " 64 | 65 | for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do 66 | local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]}" 67 | local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]}" 68 | local assertions="${_BASHUNIT_REPORTS_TEST_ASSERTIONS[$i]}" 69 | local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]}" 70 | local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]}" 71 | 72 | echo " " 77 | echo " " 78 | done 79 | 80 | echo " " 81 | echo "" 82 | } > "$output_file" 83 | } 84 | 85 | function bashunit::reports::generate_report_html() { 86 | local output_file="$1" 87 | 88 | local test_passed=$(bashunit::state::get_tests_passed) 89 | local tests_skipped=$(bashunit::state::get_tests_skipped) 90 | local tests_incomplete=$(bashunit::state::get_tests_incomplete) 91 | local tests_snapshot=$(bashunit::state::get_tests_snapshot) 92 | local tests_failed=$(bashunit::state::get_tests_failed) 93 | local time=$(bashunit::clock::total_runtime_in_milliseconds) 94 | 95 | # Temporary file to store test cases by file 96 | local temp_file="temp_test_cases.txt" 97 | 98 | # Collect test cases by file 99 | : > "$temp_file" # Clear temp file if it exists 100 | for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do 101 | local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]}" 102 | local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]}" 103 | local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]}" 104 | local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]}" 105 | local test_case="$file|$name|$status|$test_time" 106 | 107 | echo "$test_case" >> "$temp_file" 108 | done 109 | 110 | { 111 | echo "" 112 | echo "" 113 | echo "" 114 | echo " " 115 | echo " " 116 | echo " Test Report" 117 | echo " " 128 | echo "" 129 | echo "" 130 | echo "

Test Report

" 131 | echo " " 132 | echo " " 133 | echo " " 134 | echo " " 135 | echo " " 136 | echo " " 137 | echo " " 138 | echo " " 139 | echo " " 140 | echo " " 141 | echo " " 142 | echo " " 143 | echo " " 144 | echo " " 145 | echo " " 146 | echo " " 147 | echo " " 148 | echo " " 149 | echo " " 150 | echo " " 151 | echo " " 152 | echo " " 153 | echo " " 154 | echo "
Total TestsPassedFailedIncompleteSkippedSnapshotTime (ms)
${#_BASHUNIT_REPORTS_TEST_NAMES[@]}$test_passed$tests_failed$tests_incomplete$tests_skipped$tests_snapshot$time
" 155 | echo "

Time: $time ms

" 156 | 157 | # Read the temporary file and group by file 158 | local current_file="" 159 | while IFS='|' read -r file name status test_time; do 160 | if [ "$file" != "$current_file" ]; then 161 | if [ -n "$current_file" ]; then 162 | echo " " 163 | echo " " 164 | fi 165 | echo "

File: $file

" 166 | echo " " 167 | echo " " 168 | echo " " 169 | echo " " 170 | echo " " 171 | echo " " 172 | echo " " 173 | echo " " 174 | echo " " 175 | current_file="$file" 176 | fi 177 | echo " " 178 | echo " " 179 | echo " " 180 | echo " " 181 | echo " " 182 | done < "$temp_file" 183 | 184 | # Close the last table 185 | if [ -n "$current_file" ]; then 186 | echo " " 187 | echo "
Test NameStatusTime (ms)
$name$status$test_time
" 188 | fi 189 | 190 | echo "" 191 | echo "" 192 | } > "$output_file" 193 | 194 | # Clean up temporary file 195 | rm -f "$temp_file" 196 | } 197 | -------------------------------------------------------------------------------- /src/env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # shellcheck disable=SC2034 4 | 5 | # Load .env file (skip if --skip-env-file is used to keep shell environment intact) 6 | if [[ "${BASHUNIT_SKIP_ENV_FILE:-false}" != "true" ]]; then 7 | set -o allexport 8 | # shellcheck source=/dev/null 9 | [[ -f ".env" ]] && source .env 10 | set +o allexport 11 | fi 12 | 13 | _BASHUNIT_DEFAULT_DEFAULT_PATH="tests" 14 | _BASHUNIT_DEFAULT_BOOTSTRAP="tests/bootstrap.sh" 15 | _BASHUNIT_DEFAULT_DEV_LOG="" 16 | _BASHUNIT_DEFAULT_LOG_JUNIT="" 17 | _BASHUNIT_DEFAULT_REPORT_HTML="" 18 | 19 | : "${BASHUNIT_DEFAULT_PATH:=${DEFAULT_PATH:=$_BASHUNIT_DEFAULT_DEFAULT_PATH}}" 20 | : "${BASHUNIT_DEV_LOG:=${DEV_LOG:=$_BASHUNIT_DEFAULT_DEV_LOG}}" 21 | : "${BASHUNIT_BOOTSTRAP:=${BOOTSTRAP:=$_BASHUNIT_DEFAULT_BOOTSTRAP}}" 22 | : "${BASHUNIT_BOOTSTRAP_ARGS:=${BOOTSTRAP_ARGS:=}}" 23 | : "${BASHUNIT_LOG_JUNIT:=${LOG_JUNIT:=$_BASHUNIT_DEFAULT_LOG_JUNIT}}" 24 | : "${BASHUNIT_REPORT_HTML:=${REPORT_HTML:=$_BASHUNIT_DEFAULT_REPORT_HTML}}" 25 | 26 | # Booleans 27 | _BASHUNIT_DEFAULT_PARALLEL_RUN="false" 28 | _BASHUNIT_DEFAULT_SHOW_HEADER="true" 29 | _BASHUNIT_DEFAULT_HEADER_ASCII_ART="false" 30 | _BASHUNIT_DEFAULT_SIMPLE_OUTPUT="false" 31 | _BASHUNIT_DEFAULT_STOP_ON_FAILURE="false" 32 | _BASHUNIT_DEFAULT_SHOW_EXECUTION_TIME="true" 33 | _BASHUNIT_DEFAULT_VERBOSE="false" 34 | _BASHUNIT_DEFAULT_BENCH_MODE="false" 35 | _BASHUNIT_DEFAULT_NO_OUTPUT="false" 36 | _BASHUNIT_DEFAULT_INTERNAL_LOG="false" 37 | _BASHUNIT_DEFAULT_SHOW_SKIPPED="false" 38 | _BASHUNIT_DEFAULT_SHOW_INCOMPLETE="false" 39 | _BASHUNIT_DEFAULT_STRICT_MODE="false" 40 | _BASHUNIT_DEFAULT_STOP_ON_ASSERTION_FAILURE="true" 41 | _BASHUNIT_DEFAULT_SKIP_ENV_FILE="false" 42 | _BASHUNIT_DEFAULT_LOGIN_SHELL="false" 43 | _BASHUNIT_DEFAULT_FAILURES_ONLY="false" 44 | _BASHUNIT_DEFAULT_NO_COLOR="false" 45 | 46 | : "${BASHUNIT_PARALLEL_RUN:=${PARALLEL_RUN:=$_BASHUNIT_DEFAULT_PARALLEL_RUN}}" 47 | : "${BASHUNIT_SHOW_HEADER:=${SHOW_HEADER:=$_BASHUNIT_DEFAULT_SHOW_HEADER}}" 48 | : "${BASHUNIT_HEADER_ASCII_ART:=${HEADER_ASCII_ART:=$_BASHUNIT_DEFAULT_HEADER_ASCII_ART}}" 49 | : "${BASHUNIT_SIMPLE_OUTPUT:=${SIMPLE_OUTPUT:=$_BASHUNIT_DEFAULT_SIMPLE_OUTPUT}}" 50 | : "${BASHUNIT_STOP_ON_FAILURE:=${STOP_ON_FAILURE:=$_BASHUNIT_DEFAULT_STOP_ON_FAILURE}}" 51 | : "${BASHUNIT_SHOW_EXECUTION_TIME:=${SHOW_EXECUTION_TIME:=$_BASHUNIT_DEFAULT_SHOW_EXECUTION_TIME}}" 52 | : "${BASHUNIT_VERBOSE:=${VERBOSE:=$_BASHUNIT_DEFAULT_VERBOSE}}" 53 | : "${BASHUNIT_BENCH_MODE:=${BENCH_MODE:=$_BASHUNIT_DEFAULT_BENCH_MODE}}" 54 | : "${BASHUNIT_NO_OUTPUT:=${NO_OUTPUT:=$_BASHUNIT_DEFAULT_NO_OUTPUT}}" 55 | : "${BASHUNIT_INTERNAL_LOG:=${INTERNAL_LOG:=$_BASHUNIT_DEFAULT_INTERNAL_LOG}}" 56 | : "${BASHUNIT_SHOW_SKIPPED:=${SHOW_SKIPPED:=$_BASHUNIT_DEFAULT_SHOW_SKIPPED}}" 57 | : "${BASHUNIT_SHOW_INCOMPLETE:=${SHOW_INCOMPLETE:=$_BASHUNIT_DEFAULT_SHOW_INCOMPLETE}}" 58 | : "${BASHUNIT_STRICT_MODE:=${STRICT_MODE:=$_BASHUNIT_DEFAULT_STRICT_MODE}}" 59 | : "${BASHUNIT_STOP_ON_ASSERTION_FAILURE:=${STOP_ON_ASSERTION_FAILURE:=$_BASHUNIT_DEFAULT_STOP_ON_ASSERTION_FAILURE}}" 60 | : "${BASHUNIT_SKIP_ENV_FILE:=${SKIP_ENV_FILE:=$_BASHUNIT_DEFAULT_SKIP_ENV_FILE}}" 61 | : "${BASHUNIT_LOGIN_SHELL:=${LOGIN_SHELL:=$_BASHUNIT_DEFAULT_LOGIN_SHELL}}" 62 | : "${BASHUNIT_FAILURES_ONLY:=${FAILURES_ONLY:=$_BASHUNIT_DEFAULT_FAILURES_ONLY}}" 63 | # Support NO_COLOR standard (https://no-color.org) 64 | if [[ -n "${NO_COLOR:-}" ]]; then 65 | BASHUNIT_NO_COLOR="true" 66 | else 67 | : "${BASHUNIT_NO_COLOR:=$_BASHUNIT_DEFAULT_NO_COLOR}" 68 | fi 69 | 70 | function bashunit::env::is_parallel_run_enabled() { 71 | [[ "$BASHUNIT_PARALLEL_RUN" == "true" ]] 72 | } 73 | 74 | function bashunit::env::is_show_header_enabled() { 75 | [[ "$BASHUNIT_SHOW_HEADER" == "true" ]] 76 | } 77 | 78 | function bashunit::env::is_header_ascii_art_enabled() { 79 | [[ "$BASHUNIT_HEADER_ASCII_ART" == "true" ]] 80 | } 81 | 82 | function bashunit::env::is_simple_output_enabled() { 83 | [[ "$BASHUNIT_SIMPLE_OUTPUT" == "true" ]] 84 | } 85 | 86 | function bashunit::env::is_stop_on_failure_enabled() { 87 | [[ "$BASHUNIT_STOP_ON_FAILURE" == "true" ]] 88 | } 89 | 90 | function bashunit::env::is_show_execution_time_enabled() { 91 | [[ "$BASHUNIT_SHOW_EXECUTION_TIME" == "true" ]] 92 | } 93 | 94 | function bashunit::env::is_dev_mode_enabled() { 95 | [[ -n "$BASHUNIT_DEV_LOG" ]] 96 | } 97 | 98 | function bashunit::env::is_internal_log_enabled() { 99 | [[ "$BASHUNIT_INTERNAL_LOG" == "true" ]] 100 | } 101 | 102 | function bashunit::env::is_verbose_enabled() { 103 | [[ "$BASHUNIT_VERBOSE" == "true" ]] 104 | } 105 | 106 | function bashunit::env::is_bench_mode_enabled() { 107 | [[ "$BASHUNIT_BENCH_MODE" == "true" ]] 108 | } 109 | 110 | function bashunit::env::is_no_output_enabled() { 111 | [[ "$BASHUNIT_NO_OUTPUT" == "true" ]] 112 | } 113 | 114 | function bashunit::env::is_show_skipped_enabled() { 115 | [[ "$BASHUNIT_SHOW_SKIPPED" == "true" ]] 116 | } 117 | 118 | function bashunit::env::is_show_incomplete_enabled() { 119 | [[ "$BASHUNIT_SHOW_INCOMPLETE" == "true" ]] 120 | } 121 | 122 | function bashunit::env::is_strict_mode_enabled() { 123 | [[ "$BASHUNIT_STRICT_MODE" == "true" ]] 124 | } 125 | 126 | function bashunit::env::is_stop_on_assertion_failure_enabled() { 127 | [[ "$BASHUNIT_STOP_ON_ASSERTION_FAILURE" == "true" ]] 128 | } 129 | 130 | function bashunit::env::is_skip_env_file_enabled() { 131 | [[ "$BASHUNIT_SKIP_ENV_FILE" == "true" ]] 132 | } 133 | 134 | function bashunit::env::is_login_shell_enabled() { 135 | [[ "$BASHUNIT_LOGIN_SHELL" == "true" ]] 136 | } 137 | 138 | function bashunit::env::is_failures_only_enabled() { 139 | [[ "$BASHUNIT_FAILURES_ONLY" == "true" ]] 140 | } 141 | 142 | function bashunit::env::is_no_color_enabled() { 143 | [[ "$BASHUNIT_NO_COLOR" == "true" ]] 144 | } 145 | 146 | function bashunit::env::active_internet_connection() { 147 | if [[ "${BASHUNIT_NO_NETWORK:-}" == "true" ]]; then 148 | return 1 149 | fi 150 | 151 | if command -v curl >/dev/null 2>&1; then 152 | curl -sfI https://github.com >/dev/null 2>&1 && return 0 153 | elif command -v wget >/dev/null 2>&1; then 154 | wget -q --spider https://github.com && return 0 155 | fi 156 | 157 | if ping -c 1 -W 3 google.com &> /dev/null; then 158 | return 0 159 | fi 160 | 161 | return 1 162 | } 163 | 164 | function bashunit::env::find_terminal_width() { 165 | local cols="" 166 | 167 | if [[ -z "$cols" ]] && command -v tput > /dev/null; then 168 | cols=$(tput cols 2>/dev/null) 169 | fi 170 | 171 | if [[ -z "$cols" ]] && command -v stty > /dev/null; then 172 | cols=$(stty size 2>/dev/null | cut -d' ' -f2) 173 | fi 174 | 175 | # Directly echo the value with fallback 176 | echo "${cols:-100}" 177 | } 178 | 179 | function bashunit::env::print_verbose() { 180 | bashunit::internal_log "Printing verbose environment variables" 181 | local keys=( 182 | "BASHUNIT_DEFAULT_PATH" 183 | "BASHUNIT_DEV_LOG" 184 | "BASHUNIT_BOOTSTRAP" 185 | "BASHUNIT_BOOTSTRAP_ARGS" 186 | "BASHUNIT_LOG_JUNIT" 187 | "BASHUNIT_REPORT_HTML" 188 | "BASHUNIT_PARALLEL_RUN" 189 | "BASHUNIT_SHOW_HEADER" 190 | "BASHUNIT_HEADER_ASCII_ART" 191 | "BASHUNIT_SIMPLE_OUTPUT" 192 | "BASHUNIT_STOP_ON_FAILURE" 193 | "BASHUNIT_SHOW_EXECUTION_TIME" 194 | "BASHUNIT_VERBOSE" 195 | "BASHUNIT_STRICT_MODE" 196 | "BASHUNIT_STOP_ON_ASSERTION_FAILURE" 197 | "BASHUNIT_SKIP_ENV_FILE" 198 | "BASHUNIT_LOGIN_SHELL" 199 | ) 200 | 201 | local max_length=0 202 | 203 | for key in "${keys[@]}"; do 204 | if (( ${#key} > max_length )); then 205 | max_length=${#key} 206 | fi 207 | done 208 | 209 | for key in "${keys[@]}"; do 210 | bashunit::internal_log "$key=${!key}" 211 | printf "%s:%*s%s\n" "$key" $((max_length - ${#key} + 1)) "" "${!key}" 212 | done 213 | } 214 | 215 | EXIT_CODE_STOP_ON_FAILURE=4 216 | # Use a unique directory per run to avoid conflicts when bashunit is invoked 217 | # recursively or multiple instances are executed in parallel. 218 | TEMP_DIR_PARALLEL_TEST_SUITE="${TMPDIR:-/tmp}/bashunit/parallel/${_BASHUNIT_OS:-Unknown}/$(bashunit::random_str 8)" 219 | TEMP_FILE_PARALLEL_STOP_ON_FAILURE="$TEMP_DIR_PARALLEL_TEST_SUITE/.stop-on-failure" 220 | TERMINAL_WIDTH="$(bashunit::env::find_terminal_width)" 221 | FAILURES_OUTPUT_PATH=$(mktemp) 222 | SKIPPED_OUTPUT_PATH=$(mktemp) 223 | INCOMPLETE_OUTPUT_PATH=$(mktemp) 224 | CAT="$(command -v cat)" 225 | 226 | # Initialize temp directory once at startup for performance 227 | BASHUNIT_TEMP_DIR="${TMPDIR:-/tmp}/bashunit/tmp" 228 | mkdir -p "$BASHUNIT_TEMP_DIR" 2>/dev/null || true 229 | 230 | if bashunit::env::is_dev_mode_enabled; then 231 | bashunit::internal_log "info" "Dev log enabled" "file:$BASHUNIT_DEV_LOG" 232 | fi 233 | -------------------------------------------------------------------------------- /src/state.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | _BASHUNIT_TESTS_PASSED=0 4 | _BASHUNIT_TESTS_FAILED=0 5 | _BASHUNIT_TESTS_SKIPPED=0 6 | _BASHUNIT_TESTS_INCOMPLETE=0 7 | _BASHUNIT_TESTS_SNAPSHOT=0 8 | _BASHUNIT_ASSERTIONS_PASSED=0 9 | _BASHUNIT_ASSERTIONS_FAILED=0 10 | _BASHUNIT_ASSERTIONS_SKIPPED=0 11 | _BASHUNIT_ASSERTIONS_INCOMPLETE=0 12 | _BASHUNIT_ASSERTIONS_SNAPSHOT=0 13 | _BASHUNIT_DUPLICATED_FUNCTION_NAMES="" 14 | _BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES="" 15 | _BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND=false 16 | _BASHUNIT_TEST_OUTPUT="" 17 | _BASHUNIT_TEST_TITLE="" 18 | _BASHUNIT_TEST_EXIT_CODE=0 19 | _BASHUNIT_TEST_HOOK_FAILURE="" 20 | _BASHUNIT_TEST_HOOK_MESSAGE="" 21 | _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="" 22 | _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 23 | 24 | function bashunit::state::get_tests_passed() { 25 | echo "$_BASHUNIT_TESTS_PASSED" 26 | } 27 | 28 | function bashunit::state::add_tests_passed() { 29 | ((_BASHUNIT_TESTS_PASSED++)) || true 30 | } 31 | 32 | function bashunit::state::get_tests_failed() { 33 | echo "$_BASHUNIT_TESTS_FAILED" 34 | } 35 | 36 | function bashunit::state::add_tests_failed() { 37 | ((_BASHUNIT_TESTS_FAILED++)) || true 38 | } 39 | 40 | function bashunit::state::get_tests_skipped() { 41 | echo "$_BASHUNIT_TESTS_SKIPPED" 42 | } 43 | 44 | function bashunit::state::add_tests_skipped() { 45 | ((_BASHUNIT_TESTS_SKIPPED++)) || true 46 | } 47 | 48 | function bashunit::state::get_tests_incomplete() { 49 | echo "$_BASHUNIT_TESTS_INCOMPLETE" 50 | } 51 | 52 | function bashunit::state::add_tests_incomplete() { 53 | ((_BASHUNIT_TESTS_INCOMPLETE++)) || true 54 | } 55 | 56 | function bashunit::state::get_tests_snapshot() { 57 | echo "$_BASHUNIT_TESTS_SNAPSHOT" 58 | } 59 | 60 | function bashunit::state::add_tests_snapshot() { 61 | ((_BASHUNIT_TESTS_SNAPSHOT++)) || true 62 | } 63 | 64 | function bashunit::state::get_assertions_passed() { 65 | echo "$_BASHUNIT_ASSERTIONS_PASSED" 66 | } 67 | 68 | function bashunit::state::add_assertions_passed() { 69 | ((_BASHUNIT_ASSERTIONS_PASSED++)) || true 70 | } 71 | 72 | function bashunit::state::get_assertions_failed() { 73 | echo "$_BASHUNIT_ASSERTIONS_FAILED" 74 | } 75 | 76 | function bashunit::state::add_assertions_failed() { 77 | ((_BASHUNIT_ASSERTIONS_FAILED++)) || true 78 | } 79 | 80 | function bashunit::state::get_assertions_skipped() { 81 | echo "$_BASHUNIT_ASSERTIONS_SKIPPED" 82 | } 83 | 84 | function bashunit::state::add_assertions_skipped() { 85 | ((_BASHUNIT_ASSERTIONS_SKIPPED++)) || true 86 | } 87 | 88 | function bashunit::state::get_assertions_incomplete() { 89 | echo "$_BASHUNIT_ASSERTIONS_INCOMPLETE" 90 | } 91 | 92 | function bashunit::state::add_assertions_incomplete() { 93 | ((_BASHUNIT_ASSERTIONS_INCOMPLETE++)) || true 94 | } 95 | 96 | function bashunit::state::get_assertions_snapshot() { 97 | echo "$_BASHUNIT_ASSERTIONS_SNAPSHOT" 98 | } 99 | 100 | function bashunit::state::add_assertions_snapshot() { 101 | ((_BASHUNIT_ASSERTIONS_SNAPSHOT++)) || true 102 | } 103 | 104 | function bashunit::state::is_duplicated_test_functions_found() { 105 | echo "$_BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND" 106 | } 107 | 108 | function bashunit::state::set_duplicated_test_functions_found() { 109 | _BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND=true 110 | } 111 | 112 | function bashunit::state::get_duplicated_function_names() { 113 | echo "$_BASHUNIT_DUPLICATED_FUNCTION_NAMES" 114 | } 115 | 116 | function bashunit::state::set_duplicated_function_names() { 117 | _BASHUNIT_DUPLICATED_FUNCTION_NAMES="$1" 118 | } 119 | 120 | function bashunit::state::get_file_with_duplicated_function_names() { 121 | echo "$_BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES" 122 | } 123 | 124 | function bashunit::state::set_file_with_duplicated_function_names() { 125 | _BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES="$1" 126 | } 127 | 128 | function bashunit::state::add_test_output() { 129 | _BASHUNIT_TEST_OUTPUT+="$1" 130 | } 131 | 132 | function bashunit::state::get_test_exit_code() { 133 | echo "$_BASHUNIT_TEST_EXIT_CODE" 134 | } 135 | 136 | function bashunit::state::set_test_exit_code() { 137 | _BASHUNIT_TEST_EXIT_CODE="$1" 138 | } 139 | 140 | function bashunit::state::get_test_title() { 141 | echo "$_BASHUNIT_TEST_TITLE" 142 | } 143 | 144 | function bashunit::state::set_test_title() { 145 | _BASHUNIT_TEST_TITLE="$1" 146 | } 147 | 148 | function bashunit::state::reset_test_title() { 149 | _BASHUNIT_TEST_TITLE="" 150 | } 151 | 152 | function bashunit::state::get_current_test_interpolated_function_name() { 153 | echo "$_BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME" 154 | } 155 | 156 | function bashunit::state::set_current_test_interpolated_function_name() { 157 | _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="$1" 158 | } 159 | 160 | function bashunit::state::reset_current_test_interpolated_function_name() { 161 | _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="" 162 | } 163 | 164 | function bashunit::state::get_test_hook_failure() { 165 | echo "$_BASHUNIT_TEST_HOOK_FAILURE" 166 | } 167 | 168 | function bashunit::state::set_test_hook_failure() { 169 | _BASHUNIT_TEST_HOOK_FAILURE="$1" 170 | } 171 | 172 | function bashunit::state::reset_test_hook_failure() { 173 | _BASHUNIT_TEST_HOOK_FAILURE="" 174 | } 175 | 176 | function bashunit::state::get_test_hook_message() { 177 | echo "$_BASHUNIT_TEST_HOOK_MESSAGE" 178 | } 179 | 180 | function bashunit::state::set_test_hook_message() { 181 | _BASHUNIT_TEST_HOOK_MESSAGE="$1" 182 | } 183 | 184 | function bashunit::state::reset_test_hook_message() { 185 | _BASHUNIT_TEST_HOOK_MESSAGE="" 186 | } 187 | 188 | function bashunit::state::is_assertion_failed_in_test() { 189 | (( _BASHUNIT_ASSERTION_FAILED_IN_TEST )) 190 | } 191 | 192 | function bashunit::state::mark_assertion_failed_in_test() { 193 | _BASHUNIT_ASSERTION_FAILED_IN_TEST=1 194 | } 195 | 196 | function bashunit::state::set_duplicated_functions_merged() { 197 | bashunit::state::set_duplicated_test_functions_found 198 | bashunit::state::set_file_with_duplicated_function_names "$1" 199 | bashunit::state::set_duplicated_function_names "$2" 200 | } 201 | 202 | function bashunit::state::initialize_assertions_count() { 203 | _BASHUNIT_ASSERTIONS_PASSED=0 204 | _BASHUNIT_ASSERTIONS_FAILED=0 205 | _BASHUNIT_ASSERTIONS_SKIPPED=0 206 | _BASHUNIT_ASSERTIONS_INCOMPLETE=0 207 | _BASHUNIT_ASSERTIONS_SNAPSHOT=0 208 | _BASHUNIT_TEST_OUTPUT="" 209 | _BASHUNIT_TEST_TITLE="" 210 | _BASHUNIT_TEST_HOOK_FAILURE="" 211 | _BASHUNIT_TEST_HOOK_MESSAGE="" 212 | _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 213 | } 214 | 215 | function bashunit::state::export_subshell_context() { 216 | local encoded_test_output 217 | local encoded_test_title 218 | 219 | local encoded_test_hook_message 220 | 221 | if base64 --help 2>&1 | grep -q -- "-w"; then 222 | # Alpine requires the -w 0 option to avoid wrapping 223 | encoded_test_output=$(echo -n "$_BASHUNIT_TEST_OUTPUT" | base64 -w 0) 224 | encoded_test_title=$(echo -n "$_BASHUNIT_TEST_TITLE" | base64 -w 0) 225 | encoded_test_hook_message=$(echo -n "$_BASHUNIT_TEST_HOOK_MESSAGE" | base64 -w 0) 226 | else 227 | # macOS and others: default base64 without wrapping 228 | encoded_test_output=$(echo -n "$_BASHUNIT_TEST_OUTPUT" | base64) 229 | encoded_test_title=$(echo -n "$_BASHUNIT_TEST_TITLE" | base64) 230 | encoded_test_hook_message=$(echo -n "$_BASHUNIT_TEST_HOOK_MESSAGE" | base64) 231 | fi 232 | 233 | cat < [arguments] [options] 64 | 65 | Commands: 66 | test [path] Run tests (default command) 67 | bench [path] Run benchmarks 68 | assert Run standalone assertion 69 | doc [filter] Display assertion documentation 70 | init [dir] Initialize a new test directory 71 | learn Start interactive tutorial 72 | upgrade Upgrade bashunit to latest version 73 | 74 | Global Options: 75 | -h, --help Show this help message 76 | -v, --version Display the current version 77 | 78 | Run 'bashunit --help' for command-specific options. 79 | 80 | Examples: 81 | bashunit test tests/ Run all tests in directory 82 | bashunit tests/ Run all tests (shorthand) 83 | bashunit bench Run all benchmarks 84 | bashunit assert equals "foo" "foo" Run standalone assertion 85 | bashunit doc contains Show docs for 'contains' assertions 86 | bashunit init Initialize test directory 87 | 88 | More info: https://bashunit.typeddevs.com/command-line 89 | EOF 90 | } 91 | 92 | function bashunit::console_header::print_test_help() { 93 | cat < Run a standalone assert function (deprecated: use 'bashunit assert') 106 | -e, --env, --boot Load a custom env/bootstrap file (supports args) 107 | -f, --filter Only run tests matching the name 108 | --log-junit Write JUnit XML report 109 | -p, --parallel Run tests in parallel 110 | --no-parallel Run tests sequentially 111 | -r, --report-html Write HTML report 112 | -s, --simple Simple output (dots) 113 | --detailed Detailed output (default) 114 | -R, --run-all Run all assertions (don't stop on first failure) 115 | -S, --stop-on-failure Stop on first failure 116 | -vvv, --verbose Show execution details 117 | --debug [file] Enable shell debug mode 118 | --no-output Suppress all output 119 | --failures-only Only show failures (suppress passed/skipped/incomplete) 120 | --strict Enable strict shell mode (set -euo pipefail) 121 | --skip-env-file Skip .env loading, use shell environment only 122 | -l, --login Run tests in login shell context 123 | --no-color Disable colored output (honors NO_COLOR env var) 124 | -h, --help Show this help message 125 | 126 | Examples: 127 | bashunit test tests/ 128 | bashunit test tests/unit/ --parallel 129 | bashunit test --filter "user" tests/ 130 | bashunit test -a equals "foo" "foo" 131 | EOF 132 | } 133 | 134 | function bashunit::console_header::print_bench_help() { 135 | cat < Load a custom env/bootstrap file (supports args) 145 | -f, --filter Only run benchmarks matching the name 146 | -s, --simple Simple output 147 | --detailed Detailed output (default) 148 | -vvv, --verbose Show execution details 149 | --skip-env-file Skip .env loading, use shell environment only 150 | -l, --login Run in login shell context 151 | --no-color Disable colored output (honors NO_COLOR env var) 152 | -h, --help Show this help message 153 | 154 | Examples: 155 | bashunit bench 156 | bashunit bench benchmarks/ 157 | bashunit bench --filter "parse" 158 | EOF 159 | } 160 | 161 | function bashunit::console_header::print_doc_help() { 162 | cat < [args...] 231 | bashunit assert "" [ ...] 232 | 233 | Run standalone assertion(s) without creating a test file. 234 | 235 | Single assertion: 236 | bashunit assert equals "foo" "foo" 237 | bashunit assert same "1" "1" 238 | bashunit assert contains "world" "hello world" 239 | bashunit assert exit_code 0 "echo 'success'" 240 | 241 | Multiple assertions on command output: 242 | bashunit assert "echo 'error' && exit 1" exit_code "1" contains "error" 243 | bashunit assert "./my_script.sh" exit_code "0" contains "success" not_contains "error" 244 | 245 | Arguments: 246 | function Assertion function name (with or without 'assert_' prefix) 247 | command Command to execute (for multi-assertion mode) 248 | assertion Assertion name (exit_code, contains, equals, etc.) 249 | arg Expected value for the assertion 250 | 251 | Note: You can also use 'bashunit test --assert ' (deprecated). 252 | The 'bashunit assert' subcommand is the recommended approach. 253 | 254 | More info: https://bashunit.typeddevs.com/standalone 255 | EOF 256 | } 257 | -------------------------------------------------------------------------------- /src/console_results.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # shellcheck disable=SC2155 3 | 4 | _BASHUNIT_TOTAL_TESTS_COUNT=0 5 | 6 | function bashunit::console_results::render_result() { 7 | if [[ "$(bashunit::state::is_duplicated_test_functions_found)" == true ]]; then 8 | bashunit::console_results::print_execution_time 9 | printf "%s%s%s\n" "${_BASHUNIT_COLOR_RETURN_ERROR}" "Duplicate test functions found" "${_BASHUNIT_COLOR_DEFAULT}" 10 | printf "File with duplicate functions: %s\n" "$(bashunit::state::get_file_with_duplicated_function_names)" 11 | printf "Duplicate functions: %s\n" "$(bashunit::state::get_duplicated_function_names)" 12 | return 1 13 | fi 14 | 15 | if bashunit::env::is_simple_output_enabled; then 16 | printf "\n\n" 17 | fi 18 | 19 | # Cache state values to avoid repeated subshell invocations 20 | local tests_passed=$_BASHUNIT_TESTS_PASSED 21 | local tests_skipped=$_BASHUNIT_TESTS_SKIPPED 22 | local tests_incomplete=$_BASHUNIT_TESTS_INCOMPLETE 23 | local tests_snapshot=$_BASHUNIT_TESTS_SNAPSHOT 24 | local tests_failed=$_BASHUNIT_TESTS_FAILED 25 | local assertions_passed=$_BASHUNIT_ASSERTIONS_PASSED 26 | local assertions_skipped=$_BASHUNIT_ASSERTIONS_SKIPPED 27 | local assertions_incomplete=$_BASHUNIT_ASSERTIONS_INCOMPLETE 28 | local assertions_snapshot=$_BASHUNIT_ASSERTIONS_SNAPSHOT 29 | local assertions_failed=$_BASHUNIT_ASSERTIONS_FAILED 30 | 31 | local total_tests=0 32 | ((total_tests += tests_passed)) || true 33 | ((total_tests += tests_skipped)) || true 34 | ((total_tests += tests_incomplete)) || true 35 | ((total_tests += tests_snapshot)) || true 36 | ((total_tests += tests_failed)) || true 37 | 38 | local total_assertions=0 39 | ((total_assertions += assertions_passed)) || true 40 | ((total_assertions += assertions_skipped)) || true 41 | ((total_assertions += assertions_incomplete)) || true 42 | ((total_assertions += assertions_snapshot)) || true 43 | ((total_assertions += assertions_failed)) || true 44 | 45 | printf "%sTests: %s" "$_BASHUNIT_COLOR_FAINT" "$_BASHUNIT_COLOR_DEFAULT" 46 | if [[ "$tests_passed" -gt 0 ]] || [[ "$assertions_passed" -gt 0 ]]; then 47 | printf " %s%s passed%s," "$_BASHUNIT_COLOR_PASSED" "$tests_passed" "$_BASHUNIT_COLOR_DEFAULT" 48 | fi 49 | if [[ "$tests_skipped" -gt 0 ]] || [[ "$assertions_skipped" -gt 0 ]]; then 50 | printf " %s%s skipped%s," "$_BASHUNIT_COLOR_SKIPPED" "$tests_skipped" "$_BASHUNIT_COLOR_DEFAULT" 51 | fi 52 | if [[ "$tests_incomplete" -gt 0 ]] || [[ "$assertions_incomplete" -gt 0 ]]; then 53 | printf " %s%s incomplete%s," "$_BASHUNIT_COLOR_INCOMPLETE" "$tests_incomplete" "$_BASHUNIT_COLOR_DEFAULT" 54 | fi 55 | if [[ "$tests_snapshot" -gt 0 ]] || [[ "$assertions_snapshot" -gt 0 ]]; then 56 | printf " %s%s snapshot%s," "$_BASHUNIT_COLOR_SNAPSHOT" "$tests_snapshot" "$_BASHUNIT_COLOR_DEFAULT" 57 | fi 58 | if [[ "$tests_failed" -gt 0 ]] || [[ "$assertions_failed" -gt 0 ]]; then 59 | printf " %s%s failed%s," "$_BASHUNIT_COLOR_FAILED" "$tests_failed" "$_BASHUNIT_COLOR_DEFAULT" 60 | fi 61 | printf " %s total\n" "$total_tests" 62 | 63 | printf "%sAssertions:%s" "$_BASHUNIT_COLOR_FAINT" "$_BASHUNIT_COLOR_DEFAULT" 64 | if [[ "$tests_passed" -gt 0 ]] || [[ "$assertions_passed" -gt 0 ]]; then 65 | printf " %s%s passed%s," "$_BASHUNIT_COLOR_PASSED" "$assertions_passed" "$_BASHUNIT_COLOR_DEFAULT" 66 | fi 67 | if [[ "$tests_skipped" -gt 0 ]] || [[ "$assertions_skipped" -gt 0 ]]; then 68 | printf " %s%s skipped%s," "$_BASHUNIT_COLOR_SKIPPED" "$assertions_skipped" "$_BASHUNIT_COLOR_DEFAULT" 69 | fi 70 | if [[ "$tests_incomplete" -gt 0 ]] || [[ "$assertions_incomplete" -gt 0 ]]; then 71 | printf " %s%s incomplete%s," "$_BASHUNIT_COLOR_INCOMPLETE" "$assertions_incomplete" "$_BASHUNIT_COLOR_DEFAULT" 72 | fi 73 | if [[ "$tests_snapshot" -gt 0 ]] || [[ "$assertions_snapshot" -gt 0 ]]; then 74 | printf " %s%s snapshot%s," "$_BASHUNIT_COLOR_SNAPSHOT" "$assertions_snapshot" "$_BASHUNIT_COLOR_DEFAULT" 75 | fi 76 | if [[ "$tests_failed" -gt 0 ]] || [[ "$assertions_failed" -gt 0 ]]; then 77 | printf " %s%s failed%s," "$_BASHUNIT_COLOR_FAILED" "$assertions_failed" "$_BASHUNIT_COLOR_DEFAULT" 78 | fi 79 | printf " %s total\n" "$total_assertions" 80 | 81 | if [[ "$tests_failed" -gt 0 ]]; then 82 | printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_ERROR" " Some tests failed " "$_BASHUNIT_COLOR_DEFAULT" 83 | bashunit::console_results::print_execution_time 84 | return 1 85 | fi 86 | 87 | if [[ "$tests_incomplete" -gt 0 ]]; then 88 | printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_INCOMPLETE" " Some tests incomplete " "$_BASHUNIT_COLOR_DEFAULT" 89 | bashunit::console_results::print_execution_time 90 | return 0 91 | fi 92 | 93 | if [[ "$tests_skipped" -gt 0 ]]; then 94 | printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_SKIPPED" " Some tests skipped " "$_BASHUNIT_COLOR_DEFAULT" 95 | bashunit::console_results::print_execution_time 96 | return 0 97 | fi 98 | 99 | if [[ "$tests_snapshot" -gt 0 ]]; then 100 | printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_SNAPSHOT" " Some snapshots created " "$_BASHUNIT_COLOR_DEFAULT" 101 | bashunit::console_results::print_execution_time 102 | return 0 103 | fi 104 | 105 | if [[ $total_tests -eq 0 ]]; then 106 | printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_ERROR" " No tests found " "$_BASHUNIT_COLOR_DEFAULT" 107 | bashunit::console_results::print_execution_time 108 | return 1 109 | fi 110 | 111 | printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_SUCCESS" " All tests passed " "$_BASHUNIT_COLOR_DEFAULT" 112 | bashunit::console_results::print_execution_time 113 | return 0 114 | } 115 | 116 | function bashunit::console_results::print_execution_time() { 117 | if ! bashunit::env::is_show_execution_time_enabled; then 118 | return 119 | fi 120 | 121 | local time=$(bashunit::clock::total_runtime_in_milliseconds | awk '{printf "%.0f", $1}') 122 | 123 | if [[ "$time" -lt 1000 ]]; then 124 | printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" \ 125 | "Time taken: $time ms" 126 | return 127 | fi 128 | 129 | local time_in_seconds=$(( time / 1000 )) 130 | 131 | if [[ "$time_in_seconds" -ge 60 ]]; then 132 | local minutes=$(( time_in_seconds / 60 )) 133 | local seconds=$(( time_in_seconds % 60 )) 134 | printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" \ 135 | "Time taken: ${minutes}m ${seconds}s" 136 | return 137 | fi 138 | 139 | local remainder_ms=$(( time % 1000 )) 140 | local formatted_seconds=$(echo "$time_in_seconds.$remainder_ms" | awk '{printf "%.0f", $1}') 141 | 142 | printf "${_BASHUNIT_COLOR_BOLD}%s${_BASHUNIT_COLOR_DEFAULT}\n" \ 143 | "Time taken: $formatted_seconds s" 144 | } 145 | 146 | function bashunit::console_results::print_successful_test() { 147 | local test_name=$1 148 | shift 149 | local duration=${1:-"0"} 150 | shift 151 | 152 | local line 153 | if [[ -z "$*" ]]; then 154 | line=$(printf "%s✓ Passed%s: %s" "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_DEFAULT" "$test_name") 155 | else 156 | local quoted_args="" 157 | for arg in "$@"; do 158 | if [[ -z "$quoted_args" ]]; then 159 | quoted_args="'$arg'" 160 | else 161 | quoted_args="$quoted_args, '$arg'" 162 | fi 163 | done 164 | line=$(printf "%s✓ Passed%s: %s (%s)" \ 165 | "$_BASHUNIT_COLOR_PASSED" "$_BASHUNIT_COLOR_DEFAULT" "$test_name" "$quoted_args") 166 | fi 167 | 168 | local full_line=$line 169 | if bashunit::env::is_show_execution_time_enabled; then 170 | local time_display 171 | if [[ "$duration" -ge 60000 ]]; then 172 | local time_in_seconds=$(( duration / 1000 )) 173 | local minutes=$(( time_in_seconds / 60 )) 174 | local seconds=$(( time_in_seconds % 60 )) 175 | time_display="${minutes}m ${seconds}s" 176 | elif [[ "$duration" -ge 1000 ]]; then 177 | local time_in_seconds=$(( duration / 1000 )) 178 | local remainder_ms=$(( duration % 1000 )) 179 | local formatted_seconds=$(echo "$time_in_seconds.$remainder_ms" | awk '{printf "%.0f", $1}') 180 | time_display="${formatted_seconds}s" 181 | else 182 | time_display="${duration}ms" 183 | fi 184 | full_line="$(printf "%s\n" "$(bashunit::str::rpad "$line" "$time_display")")" 185 | fi 186 | 187 | bashunit::state::print_line "successful" "$full_line" 188 | } 189 | 190 | function bashunit::console_results::print_failure_message() { 191 | local test_name=$1 192 | local failure_message=$2 193 | 194 | local line 195 | line="$(printf "\ 196 | ${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s 197 | ${_BASHUNIT_COLOR_FAINT}Message:${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n"\ 198 | "${test_name}" "${failure_message}")" 199 | 200 | bashunit::state::print_line "failure" "$line" 201 | } 202 | 203 | function bashunit::console_results::print_failed_test() { 204 | local function_name=$1 205 | local expected=$2 206 | local failure_condition_message=$3 207 | local actual=$4 208 | local extra_key=${5-} 209 | local extra_value=${6-} 210 | 211 | local line 212 | line="$(printf "\ 213 | ${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s 214 | ${_BASHUNIT_COLOR_FAINT}Expected${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT} 215 | ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ 216 | "${function_name}" "${expected}" "${failure_condition_message}" "${actual}")" 217 | 218 | if [ -n "$extra_key" ]; then 219 | line+="$(printf "\ 220 | 221 | ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT} ${_BASHUNIT_COLOR_BOLD}'%s'${_BASHUNIT_COLOR_DEFAULT}\n" \ 222 | "${extra_key}" "${extra_value}")" 223 | fi 224 | 225 | bashunit::state::print_line "failed" "$line" 226 | } 227 | 228 | 229 | function bashunit::console_results::print_failed_snapshot_test() { 230 | local function_name=$1 231 | local snapshot_file=$2 232 | local actual_content=${3-} 233 | 234 | local line 235 | line="$(printf "${_BASHUNIT_COLOR_FAILED}✗ Failed${_BASHUNIT_COLOR_DEFAULT}: %s 236 | ${_BASHUNIT_COLOR_FAINT}Expected to match the snapshot${_BASHUNIT_COLOR_DEFAULT}\n" "$function_name")" 237 | 238 | if bashunit::dependencies::has_git; then 239 | local actual_file="${snapshot_file}.tmp" 240 | echo "$actual_content" > "$actual_file" 241 | 242 | local git_diff_output 243 | git_diff_output="$(git diff --no-index --word-diff --color=always \ 244 | "$snapshot_file" "$actual_file" 2>/dev/null \ 245 | | tail -n +6 | sed "s/^/ /")" 246 | 247 | line+="$git_diff_output" 248 | rm "$actual_file" 249 | fi 250 | 251 | bashunit::state::print_line "failed_snapshot" "$line" 252 | } 253 | 254 | function bashunit::console_results::print_skipped_test() { 255 | local function_name=$1 256 | local reason=${2-} 257 | 258 | local line 259 | line="$(printf "${_BASHUNIT_COLOR_SKIPPED}↷ Skipped${_BASHUNIT_COLOR_DEFAULT}: %s\n" "${function_name}")" 260 | 261 | if [[ -n "$reason" ]]; then 262 | line+="$(printf "${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}\n" "${reason}")" 263 | fi 264 | 265 | bashunit::state::print_line "skipped" "$line" 266 | } 267 | 268 | function bashunit::console_results::print_incomplete_test() { 269 | local function_name=$1 270 | local pending=${2-} 271 | 272 | local line 273 | line="$(printf "${_BASHUNIT_COLOR_INCOMPLETE}✒ Incomplete${_BASHUNIT_COLOR_DEFAULT}: %s\n" "${function_name}")" 274 | 275 | if [[ -n "$pending" ]]; then 276 | line+="$(printf "${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}\n" "${pending}")" 277 | fi 278 | 279 | bashunit::state::print_line "incomplete" "$line" 280 | } 281 | 282 | function bashunit::console_results::print_snapshot_test() { 283 | local function_name=$1 284 | local test_name 285 | test_name=$(bashunit::helper::normalize_test_function_name "$function_name") 286 | 287 | local line 288 | line="$(printf "${_BASHUNIT_COLOR_SNAPSHOT}✎ Snapshot${_BASHUNIT_COLOR_DEFAULT}: %s\n" "${test_name}")" 289 | 290 | bashunit::state::print_line "snapshot" "$line" 291 | } 292 | 293 | function bashunit::console_results::print_error_test() { 294 | local function_name=$1 295 | local error="$2" 296 | 297 | local test_name 298 | test_name=$(bashunit::helper::normalize_test_function_name "$function_name") 299 | 300 | local line 301 | line="$(printf "${_BASHUNIT_COLOR_FAILED}✗ Error${_BASHUNIT_COLOR_DEFAULT}: %s 302 | ${_BASHUNIT_COLOR_FAINT}%s${_BASHUNIT_COLOR_DEFAULT}\n" "${test_name}" "${error}")" 303 | 304 | bashunit::state::print_line "error" "$line" 305 | } 306 | 307 | function bashunit::console_results::print_failing_tests_and_reset() { 308 | if [[ -s "$FAILURES_OUTPUT_PATH" ]]; then 309 | local total_failed 310 | total_failed=$(bashunit::state::get_tests_failed) 311 | 312 | if bashunit::env::is_simple_output_enabled; then 313 | printf "\n\n" 314 | fi 315 | 316 | if [[ "$total_failed" -eq 1 ]]; then 317 | echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n" 318 | else 319 | echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n" 320 | fi 321 | 322 | sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/' 323 | rm "$FAILURES_OUTPUT_PATH" 324 | 325 | echo "" 326 | fi 327 | } 328 | 329 | function bashunit::console_results::print_skipped_tests_and_reset() { 330 | if [[ -s "$SKIPPED_OUTPUT_PATH" ]] && bashunit::env::is_show_skipped_enabled; then 331 | local total_skipped 332 | total_skipped=$(bashunit::state::get_tests_skipped) 333 | 334 | if bashunit::env::is_simple_output_enabled; then 335 | printf "\n" 336 | fi 337 | 338 | if [[ "$total_skipped" -eq 1 ]]; then 339 | echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 skipped test:${_BASHUNIT_COLOR_DEFAULT}\n" 340 | else 341 | echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_skipped skipped tests:${_BASHUNIT_COLOR_DEFAULT}\n" 342 | fi 343 | 344 | tr -d '\r' < "$SKIPPED_OUTPUT_PATH" | sed '/^[[:space:]]*$/d' | sed 's/^/|/' 345 | rm "$SKIPPED_OUTPUT_PATH" 346 | 347 | echo "" 348 | fi 349 | } 350 | 351 | function bashunit::console_results::print_incomplete_tests_and_reset() { 352 | if [[ -s "$INCOMPLETE_OUTPUT_PATH" ]] && bashunit::env::is_show_incomplete_enabled; then 353 | local total_incomplete 354 | total_incomplete=$(bashunit::state::get_tests_incomplete) 355 | 356 | if bashunit::env::is_simple_output_enabled; then 357 | printf "\n" 358 | fi 359 | 360 | if [[ "$total_incomplete" -eq 1 ]]; then 361 | echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 incomplete test:${_BASHUNIT_COLOR_DEFAULT}\n" 362 | else 363 | echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_incomplete incomplete tests:${_BASHUNIT_COLOR_DEFAULT}\n" 364 | fi 365 | 366 | tr -d '\r' < "$INCOMPLETE_OUTPUT_PATH" | sed '/^[[:space:]]*$/d' | sed 's/^/|/' 367 | rm "$INCOMPLETE_OUTPUT_PATH" 368 | 369 | echo "" 370 | fi 371 | } 372 | -------------------------------------------------------------------------------- /src/helpers.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | declare -r BASHUNIT_GIT_REPO="https://github.com/TypedDevs/bashunit" 4 | 5 | # 6 | # Walks up the call stack to find the first function that looks like a test function. 7 | # A test function is one that starts with "test_" or "test" (camelCase). 8 | # If no test function is found, falls back to the caller of the assertion function. 9 | # 10 | # @param $1 number Optional fallback depth (default: 2, i.e., the caller of the assertion) 11 | # 12 | # @return string The test function name, or fallback function name 13 | # 14 | function bashunit::helper::find_test_function_name() { 15 | local fallback_depth="${1:-2}" 16 | local i 17 | for ((i = 0; i < ${#FUNCNAME[@]}; i++)); do 18 | local fn="${FUNCNAME[$i]}" 19 | # Check if function starts with "test_" or "test" followed by uppercase 20 | if [[ "$fn" == test_* ]] || [[ "$fn" =~ ^test[A-Z] ]]; then 21 | echo "$fn" 22 | return 23 | fi 24 | done 25 | # No test function found, use fallback (caller of the assertion) 26 | # FUNCNAME[0] = bashunit::helper::find_test_function_name 27 | # FUNCNAME[1] = the assertion function (e.g., assert_same) 28 | # FUNCNAME[2] = caller of the assertion 29 | echo "${FUNCNAME[$fallback_depth]:-}" 30 | } 31 | 32 | # 33 | # @param $1 string Eg: "test_some_logic_camelCase" 34 | # 35 | # @return string Eg: "Some logic camelCase" 36 | # 37 | function bashunit::helper::normalize_test_function_name() { 38 | local original_fn_name="${1-}" 39 | local interpolated_fn_name="${2-}" 40 | 41 | local custom_title 42 | custom_title="$(bashunit::state::get_test_title)" 43 | if [[ -n "$custom_title" ]]; then 44 | echo "$custom_title" 45 | return 46 | fi 47 | 48 | if [[ -z "${interpolated_fn_name-}" && "${original_fn_name}" == *"::"* ]]; then 49 | local state_interpolated_fn_name 50 | state_interpolated_fn_name="$(bashunit::state::get_current_test_interpolated_function_name)" 51 | 52 | if [[ -n "$state_interpolated_fn_name" ]]; then 53 | interpolated_fn_name="$state_interpolated_fn_name" 54 | fi 55 | fi 56 | 57 | if [[ -n "${interpolated_fn_name-}" ]]; then 58 | original_fn_name="$interpolated_fn_name" 59 | fi 60 | 61 | local result 62 | 63 | # Remove the first "test_" prefix, if present 64 | result="${original_fn_name#test_}" 65 | # If no "test_" was removed (e.g., "testFoo"), remove the "test" prefix 66 | if [[ "$result" == "$original_fn_name" ]]; then 67 | result="${original_fn_name#test}" 68 | fi 69 | # Replace underscores with spaces 70 | result="${result//_/ }" 71 | # Capitalize the first letter (bash 3.2 compatible, no subprocess) 72 | local first_char="${result:0:1}" 73 | case "$first_char" in 74 | a) first_char='A' ;; b) first_char='B' ;; c) first_char='C' ;; d) first_char='D' ;; 75 | e) first_char='E' ;; f) first_char='F' ;; g) first_char='G' ;; h) first_char='H' ;; 76 | i) first_char='I' ;; j) first_char='J' ;; k) first_char='K' ;; l) first_char='L' ;; 77 | m) first_char='M' ;; n) first_char='N' ;; o) first_char='O' ;; p) first_char='P' ;; 78 | q) first_char='Q' ;; r) first_char='R' ;; s) first_char='S' ;; t) first_char='T' ;; 79 | u) first_char='U' ;; v) first_char='V' ;; w) first_char='W' ;; x) first_char='X' ;; 80 | y) first_char='Y' ;; z) first_char='Z' ;; 81 | esac 82 | result="${first_char}${result:1}" 83 | 84 | echo "$result" 85 | } 86 | 87 | function bashunit::helper::escape_single_quotes() { 88 | local value="$1" 89 | # shellcheck disable=SC1003 90 | echo "${value//\'/'\'\\''\'}" 91 | } 92 | 93 | function bashunit::helper::interpolate_function_name() { 94 | local function_name="$1" 95 | shift 96 | local args=("$@") 97 | local result="$function_name" 98 | 99 | for ((i=0; i<${#args[@]}; i++)); do 100 | local placeholder="::$((i+1))::" 101 | # shellcheck disable=SC2155 102 | local value="$(bashunit::helper::escape_single_quotes "${args[$i]}")" 103 | value="'$value'" 104 | result="${result//${placeholder}/${value}}" 105 | done 106 | 107 | echo "$result" 108 | } 109 | 110 | function bashunit::helper::encode_base64() { 111 | local value="$1" 112 | 113 | if command -v base64 >/dev/null; then 114 | printf '%s' "$value" | base64 -w 0 2>/dev/null || printf '%s' "$value" | base64 | tr -d '\n' 115 | else 116 | printf '%s' "$value" | openssl enc -base64 -A 117 | fi 118 | } 119 | 120 | function bashunit::helper::decode_base64() { 121 | local value="$1" 122 | 123 | if command -v base64 >/dev/null; then 124 | printf '%s' "$value" | base64 -d 125 | else 126 | printf '%s' "$value" | openssl enc -d -base64 127 | fi 128 | } 129 | 130 | function bashunit::helper::check_duplicate_functions() { 131 | local script="$1" 132 | 133 | # Handle directory changes in set_up_before_script (issue #529) 134 | if [[ ! -f "$script" && -n "${BASHUNIT_WORKING_DIR:-}" ]]; then 135 | script="$BASHUNIT_WORKING_DIR/$script" 136 | fi 137 | 138 | local filtered_lines 139 | filtered_lines=$(grep -E '^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*\s*\(\)\s*\{' "$script") 140 | 141 | local function_names 142 | function_names=$(echo "$filtered_lines" | awk '{ 143 | for (i=1; i<=NF; i++) { 144 | if ($i ~ /^test[a-zA-Z_][a-zA-Z0-9_]*\(\)$/) { 145 | gsub(/\(\)/, "", $i) 146 | print $i 147 | break 148 | } 149 | } 150 | }') 151 | 152 | local duplicates 153 | duplicates=$(echo "$function_names" | sort | uniq -d) 154 | if [ -n "$duplicates" ]; then 155 | bashunit::state::set_duplicated_functions_merged "$script" "$duplicates" 156 | return 1 157 | fi 158 | return 0 159 | } 160 | 161 | # 162 | # @param $1 string Eg: "prefix" 163 | # @param $2 string Eg: "filter" 164 | # @param $3 array Eg: "[fn1, fn2, prefix_filter_fn3, fn4, ...]" 165 | # 166 | # @return array Eg: "[prefix_filter_fn3, ...]" The filtered functions with prefix 167 | # 168 | function bashunit::helper::get_functions_to_run() { 169 | local prefix=$1 170 | local filter=${2/test_/} 171 | local function_names=$3 172 | 173 | local filtered_functions="" 174 | 175 | for fn in $function_names; do 176 | if [[ $fn == ${prefix}_*${filter}* ]]; then 177 | if [[ $filtered_functions == *" $fn"* ]]; then 178 | return 1 179 | fi 180 | filtered_functions+=" $fn" 181 | fi 182 | done 183 | 184 | echo "${filtered_functions# }" 185 | } 186 | 187 | # 188 | # @param $1 string Eg: "do_something" 189 | # 190 | function bashunit::helper::execute_function_if_exists() { 191 | local fn_name="$1" 192 | 193 | if declare -F "$fn_name" >/dev/null 2>&1; then 194 | "$fn_name" 195 | return $? 196 | fi 197 | 198 | return 0 199 | } 200 | 201 | # 202 | # @param $1 string Eg: "do_something" 203 | # 204 | function bashunit::helper::unset_if_exists() { 205 | unset "$1" 2>/dev/null 206 | } 207 | 208 | function bashunit::helper::find_files_recursive() { 209 | ## Remove trailing slash using parameter expansion 210 | local path="${1%%/}" 211 | local pattern="${2:-*[tT]est.sh}" 212 | 213 | local alt_pattern="" 214 | if [[ $pattern == *test.sh ]] || [[ $pattern =~ \[tT\]est\.sh$ ]]; then 215 | alt_pattern="${pattern%.sh}.bash" 216 | fi 217 | 218 | if [[ "$path" == *"*"* ]]; then 219 | if [[ -n $alt_pattern ]]; then 220 | eval "find $path -type f \( -name \"$pattern\" -o -name \"$alt_pattern\" \)" | sort -u 221 | else 222 | eval "find $path -type f -name \"$pattern\"" | sort -u 223 | fi 224 | elif [[ -d "$path" ]]; then 225 | if [[ -n $alt_pattern ]]; then 226 | find "$path" -type f \( -name "$pattern" -o -name "$alt_pattern" \) | sort -u 227 | else 228 | find "$path" -type f -name "$pattern" | sort -u 229 | fi 230 | else 231 | echo "$path" 232 | fi 233 | } 234 | 235 | function bashunit::helper::normalize_variable_name() { 236 | local input_string="$1" 237 | local normalized_string 238 | 239 | normalized_string="${input_string//[^a-zA-Z0-9_]/_}" 240 | 241 | if [[ ! $normalized_string =~ ^[a-zA-Z_] ]]; then 242 | normalized_string="_$normalized_string" 243 | fi 244 | 245 | echo "$normalized_string" 246 | } 247 | 248 | function bashunit::helper::get_provider_data() { 249 | local function_name="$1" 250 | local script="$2" 251 | 252 | # Handle directory changes in set_up_before_script (issue #529) 253 | # If relative path doesn't exist, try with BASHUNIT_WORKING_DIR 254 | if [[ ! -f "$script" && -n "${BASHUNIT_WORKING_DIR:-}" ]]; then 255 | script="$BASHUNIT_WORKING_DIR/$script" 256 | fi 257 | 258 | if [[ ! -f "$script" ]]; then 259 | return 260 | fi 261 | 262 | local data_provider_function 263 | data_provider_function=$( 264 | # shellcheck disable=SC1087 265 | grep -B 2 -E "function[[:space:]]+$function_name[[:space:]]*\(\)" "$script" 2>/dev/null | \ 266 | sed -nE 's/^[[:space:]]*# *@?data_provider[[:space:]]+//p' 267 | ) 268 | 269 | if [[ -n "$data_provider_function" ]]; then 270 | bashunit::helper::execute_function_if_exists "$data_provider_function" 271 | fi 272 | } 273 | 274 | function bashunit::helper::trim() { 275 | local input_string="$1" 276 | local trimmed_string 277 | 278 | trimmed_string="${input_string#"${input_string%%[![:space:]]*}"}" 279 | trimmed_string="${trimmed_string%"${trimmed_string##*[![:space:]]}"}" 280 | 281 | echo "$trimmed_string" 282 | } 283 | 284 | function bashunit::helper::get_latest_tag() { 285 | if ! bashunit::dependencies::has_git; then 286 | return 1 287 | fi 288 | 289 | git ls-remote --tags "$BASHUNIT_GIT_REPO" | 290 | awk '{print $2}' | 291 | sed 's|^refs/tags/||' | 292 | sort -Vr | 293 | head -n 1 294 | } 295 | 296 | function bashunit::helper::find_total_tests() { 297 | local filter=${1:-} 298 | local files=("${@:2}") 299 | 300 | if [[ ${#files[@]} -eq 0 ]]; then 301 | echo 0 302 | return 303 | fi 304 | 305 | local total_count=0 306 | local file 307 | 308 | for file in "${files[@]}"; do 309 | if [[ ! -f "$file" ]]; then 310 | continue 311 | fi 312 | 313 | local file_count 314 | file_count=$( ( 315 | # shellcheck source=/dev/null 316 | source "$file" 317 | local all_fn_names 318 | all_fn_names=$(declare -F | awk '{print $3}') 319 | local filtered_functions 320 | filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$all_fn_names") || true 321 | 322 | local count=0 323 | if [[ -n "$filtered_functions" ]]; then 324 | # shellcheck disable=SC2206 325 | # shellcheck disable=SC2207 326 | local functions_to_run=($filtered_functions) 327 | for fn_name in "${functions_to_run[@]}"; do 328 | local provider_data=() 329 | while IFS=" " read -r line; do 330 | provider_data+=("$line") 331 | done <<< "$(bashunit::helper::get_provider_data "$fn_name" "$file")" 332 | 333 | if [[ "${#provider_data[@]}" -eq 0 ]]; then 334 | count=$((count + 1)) 335 | else 336 | count=$((count + ${#provider_data[@]})) 337 | fi 338 | done 339 | fi 340 | 341 | echo "$count" 342 | ) ) 343 | 344 | total_count=$((total_count + file_count)) 345 | done 346 | 347 | echo "$total_count" 348 | } 349 | 350 | function bashunit::helper::load_test_files() { 351 | local filter=$1 352 | local files=("${@:2}") 353 | 354 | local test_files=() 355 | 356 | if [[ "${#files[@]}" -eq 0 ]]; then 357 | if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then 358 | while IFS='' read -r line; do 359 | test_files+=("$line") 360 | done < <(bashunit::helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH") 361 | fi 362 | else 363 | test_files=("${files[@]}") 364 | fi 365 | 366 | printf "%s\n" "${test_files[@]}" 367 | } 368 | 369 | function bashunit::helper::load_bench_files() { 370 | local filter=$1 371 | local files=("${@:2}") 372 | 373 | local bench_files=() 374 | 375 | if [[ "${#files[@]}" -eq 0 ]]; then 376 | if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then 377 | while IFS='' read -r line; do 378 | bench_files+=("$line") 379 | done < <(bashunit::helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH" '*[bB]ench.sh') 380 | fi 381 | else 382 | bench_files=("${files[@]}") 383 | fi 384 | 385 | printf "%s\n" "${bench_files[@]}" 386 | } 387 | 388 | # 389 | # @param $1 string function name 390 | # @return number line number of the function in the source file 391 | # 392 | function bashunit::helper::get_function_line_number() { 393 | local fn_name=$1 394 | 395 | shopt -s extdebug 396 | local line_number 397 | line_number=$(declare -F "$fn_name" | awk '{print $2}') 398 | shopt -u extdebug 399 | 400 | echo "$line_number" 401 | } 402 | 403 | function bashunit::helper::generate_id() { 404 | local basename="$1" 405 | local sanitized_basename 406 | sanitized_basename="$(bashunit::helper::normalize_variable_name "$basename")" 407 | if bashunit::env::is_parallel_run_enabled; then 408 | echo "${sanitized_basename}_$$_$(bashunit::random_str 6)" 409 | else 410 | echo "${sanitized_basename}_$$" 411 | fi 412 | } 413 | 414 | # 415 | # Parses a file path that may contain a filter suffix. 416 | # Supports two syntaxes: 417 | # - path::function_name (filter by function name) 418 | # - path:line_number (filter by line number) 419 | # 420 | # @param $1 string Eg: "tests/test.sh::test_foo" or "tests/test.sh:123" 421 | # 422 | # @return string Two lines: first is file path, second is filter (or empty) 423 | # 424 | function bashunit::helper::parse_file_path_filter() { 425 | local input="$1" 426 | local file_path="" 427 | local filter="" 428 | 429 | # Check for :: syntax (function name filter) 430 | if [[ "$input" == *"::"* ]]; then 431 | file_path="${input%%::*}" 432 | filter="${input#*::}" 433 | # Check for :number syntax (line number filter) 434 | elif [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then 435 | file_path="${BASH_REMATCH[1]}" 436 | local line_number="${BASH_REMATCH[2]}" 437 | # Line number will be resolved to function name later 438 | filter="__line__:${line_number}" 439 | else 440 | file_path="$input" 441 | fi 442 | 443 | echo "$file_path" 444 | echo "$filter" 445 | } 446 | 447 | # 448 | # Finds the test function that contains a given line number in a file. 449 | # 450 | # @param $1 string File path 451 | # @param $2 number Line number 452 | # 453 | # @return string The function name, or empty if not found 454 | # 455 | function bashunit::helper::find_function_at_line() { 456 | local file="$1" 457 | local target_line="$2" 458 | 459 | if [[ ! -f "$file" ]]; then 460 | return 1 461 | fi 462 | 463 | # Find all test function definitions and their line numbers 464 | local best_match="" 465 | local best_line=0 466 | 467 | while IFS=: read -r line_num content; do 468 | # Extract function name from the line 469 | local fn_name="" 470 | if [[ "$content" =~ ^[[:space:]]*(function[[:space:]]+)?(test[a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\(\) ]]; then 471 | fn_name="${BASH_REMATCH[2]}" 472 | fi 473 | 474 | if [[ -n "$fn_name" && "$line_num" -le "$target_line" && "$line_num" -gt "$best_line" ]]; then 475 | best_match="$fn_name" 476 | best_line="$line_num" 477 | fi 478 | done < <(grep -n -E '^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)' "$file") 479 | 480 | echo "$best_match" 481 | } 482 | -------------------------------------------------------------------------------- /src/main.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################# 4 | # Subcommand: test 5 | ############################# 6 | function bashunit::main::cmd_test() { 7 | local filter="" 8 | local raw_args=() 9 | local args=() 10 | local assert_fn="" 11 | 12 | # Parse test-specific options 13 | while [[ $# -gt 0 ]]; do 14 | case "$1" in 15 | -a|--assert) 16 | assert_fn="$2" 17 | shift 18 | ;; 19 | -f|--filter) 20 | filter="$2" 21 | shift 22 | ;; 23 | -s|--simple) 24 | export BASHUNIT_SIMPLE_OUTPUT=true 25 | ;; 26 | --detailed) 27 | export BASHUNIT_SIMPLE_OUTPUT=false 28 | ;; 29 | --debug) 30 | local output_file="${2:-}" 31 | if [[ -n "$output_file" && "${output_file:0:1}" != "-" ]]; then 32 | exec > "$output_file" 2>&1 33 | shift 34 | fi 35 | set -x 36 | ;; 37 | -S|--stop-on-failure) 38 | export BASHUNIT_STOP_ON_FAILURE=true 39 | ;; 40 | -p|--parallel) 41 | export BASHUNIT_PARALLEL_RUN=true 42 | ;; 43 | --no-parallel) 44 | export BASHUNIT_PARALLEL_RUN=false 45 | ;; 46 | -e|--env|--boot) 47 | # Support: --env "bootstrap.sh arg1 arg2" 48 | local boot_file="${2%% *}" 49 | local boot_args="${2#* }" 50 | if [[ "$boot_args" != "$2" ]]; then 51 | export BASHUNIT_BOOTSTRAP_ARGS="$boot_args" 52 | fi 53 | # shellcheck disable=SC1090,SC2086 54 | source "$boot_file" ${BASHUNIT_BOOTSTRAP_ARGS:-} 55 | shift 56 | ;; 57 | --log-junit) 58 | export BASHUNIT_LOG_JUNIT="$2" 59 | shift 60 | ;; 61 | -r|--report-html) 62 | export BASHUNIT_REPORT_HTML="$2" 63 | shift 64 | ;; 65 | --no-output) 66 | export BASHUNIT_NO_OUTPUT=true 67 | ;; 68 | -vvv|--verbose) 69 | export BASHUNIT_VERBOSE=true 70 | ;; 71 | -h|--help) 72 | bashunit::console_header::print_test_help 73 | exit 0 74 | ;; 75 | --show-skipped) 76 | export BASHUNIT_SHOW_SKIPPED=true 77 | ;; 78 | --show-incomplete) 79 | export BASHUNIT_SHOW_INCOMPLETE=true 80 | ;; 81 | --failures-only) 82 | export BASHUNIT_FAILURES_ONLY=true 83 | ;; 84 | --strict) 85 | export BASHUNIT_STRICT_MODE=true 86 | ;; 87 | -R|--run-all) 88 | export BASHUNIT_STOP_ON_ASSERTION_FAILURE=false 89 | ;; 90 | --skip-env-file) 91 | export BASHUNIT_SKIP_ENV_FILE=true 92 | ;; 93 | -l|--login) 94 | export BASHUNIT_LOGIN_SHELL=true 95 | ;; 96 | --no-color) 97 | # shellcheck disable=SC2034 98 | BASHUNIT_NO_COLOR=true 99 | ;; 100 | *) 101 | raw_args+=("$1") 102 | ;; 103 | esac 104 | shift 105 | done 106 | 107 | # Expand positional arguments and extract inline filters 108 | # Skip filter parsing for assert mode - args are not file paths 109 | local inline_filter="" 110 | local inline_filter_file="" 111 | if [[ ${#raw_args[@]} -gt 0 ]]; then 112 | if [[ -n "$assert_fn" ]]; then 113 | # Assert mode: pass args as-is without file path processing 114 | args=("${raw_args[@]}") 115 | else 116 | # Test mode: process file paths and extract inline filters 117 | for arg in "${raw_args[@]}"; do 118 | local parsed_path parsed_filter 119 | { 120 | read -r parsed_path 121 | read -r parsed_filter 122 | } < <(bashunit::helper::parse_file_path_filter "$arg") 123 | 124 | # If an inline filter was found, store it 125 | if [[ -n "$parsed_filter" ]]; then 126 | inline_filter="$parsed_filter" 127 | inline_filter_file="$parsed_path" 128 | fi 129 | 130 | while IFS= read -r file; do 131 | args+=("$file") 132 | done < <(bashunit::helper::find_files_recursive "$parsed_path" '*[tT]est.sh') 133 | done 134 | 135 | # Resolve line number filter to function name 136 | if [[ "$inline_filter" == "__line__:"* ]]; then 137 | local line_number="${inline_filter#__line__:}" 138 | local resolved_file="${inline_filter_file}" 139 | 140 | # If the file path was a pattern, use the first resolved file 141 | if [[ ${#args[@]} -gt 0 ]]; then 142 | resolved_file="${args[0]}" 143 | fi 144 | 145 | inline_filter=$(bashunit::helper::find_function_at_line "$resolved_file" "$line_number") 146 | if [[ -z "$inline_filter" ]]; then 147 | printf "%sError: No test function found at line %s in %s%s\n" \ 148 | "${_BASHUNIT_COLOR_FAILED}" "$line_number" "$resolved_file" "${_BASHUNIT_COLOR_DEFAULT}" 149 | exit 1 150 | fi 151 | fi 152 | 153 | # Use inline filter if no -f filter was provided 154 | if [[ -z "$filter" && -n "$inline_filter" ]]; then 155 | filter="$inline_filter" 156 | fi 157 | fi 158 | fi 159 | 160 | # Optional bootstrap 161 | # shellcheck disable=SC1090,SC2086 162 | [[ -f "${BASHUNIT_BOOTSTRAP:-}" ]] && source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} 163 | 164 | if [[ "${BASHUNIT_NO_OUTPUT:-false}" == true ]]; then 165 | exec >/dev/null 2>&1 166 | fi 167 | 168 | # Disable strict mode for test execution to allow: 169 | # - Empty array expansion (set +u) 170 | # - Non-zero exit codes from failing tests (set +e) 171 | # - Pipe failures in test output (set +o pipefail) 172 | set +euo pipefail 173 | if [[ -n "$assert_fn" ]]; then 174 | bashunit::main::exec_assert "$assert_fn" "${args[@]}" 175 | else 176 | bashunit::main::exec_tests "$filter" "${args[@]}" 177 | fi 178 | } 179 | 180 | ############################# 181 | # Subcommand: bench 182 | ############################# 183 | function bashunit::main::cmd_bench() { 184 | local filter="" 185 | local raw_args=() 186 | local args=() 187 | 188 | export BASHUNIT_BENCH_MODE=true 189 | source "$BASHUNIT_ROOT_DIR/src/benchmark.sh" 190 | 191 | # Parse bench-specific options 192 | while [[ $# -gt 0 ]]; do 193 | case "$1" in 194 | -f|--filter) 195 | filter="$2" 196 | shift 197 | ;; 198 | -s|--simple) 199 | export BASHUNIT_SIMPLE_OUTPUT=true 200 | ;; 201 | --detailed) 202 | export BASHUNIT_SIMPLE_OUTPUT=false 203 | ;; 204 | -e|--env|--boot) 205 | # Support: --env "bootstrap.sh arg1 arg2" 206 | local boot_file="${2%% *}" 207 | local boot_args="${2#* }" 208 | if [[ "$boot_args" != "$2" ]]; then 209 | export BASHUNIT_BOOTSTRAP_ARGS="$boot_args" 210 | fi 211 | # shellcheck disable=SC1090,SC2086 212 | source "$boot_file" ${BASHUNIT_BOOTSTRAP_ARGS:-} 213 | shift 214 | ;; 215 | -vvv|--verbose) 216 | export BASHUNIT_VERBOSE=true 217 | ;; 218 | --skip-env-file) 219 | export BASHUNIT_SKIP_ENV_FILE=true 220 | ;; 221 | -l|--login) 222 | export BASHUNIT_LOGIN_SHELL=true 223 | ;; 224 | --no-color) 225 | # shellcheck disable=SC2034 226 | BASHUNIT_NO_COLOR=true 227 | ;; 228 | -h|--help) 229 | bashunit::console_header::print_bench_help 230 | exit 0 231 | ;; 232 | *) 233 | raw_args+=("$1") 234 | ;; 235 | esac 236 | shift 237 | done 238 | 239 | # Expand positional arguments 240 | if [[ ${#raw_args[@]} -gt 0 ]]; then 241 | for arg in "${raw_args[@]}"; do 242 | while IFS= read -r file; do 243 | args+=("$file") 244 | done < <(bashunit::helper::find_files_recursive "$arg" '*[bB]ench.sh') 245 | done 246 | fi 247 | 248 | # Optional bootstrap 249 | # shellcheck disable=SC1090,SC2086 250 | [[ -f "${BASHUNIT_BOOTSTRAP:-}" ]] && source "$BASHUNIT_BOOTSTRAP" ${BASHUNIT_BOOTSTRAP_ARGS:-} 251 | 252 | set +euo pipefail 253 | 254 | bashunit::main::exec_benchmarks "$filter" "${args[@]}" 255 | } 256 | 257 | ############################# 258 | # Subcommand: doc 259 | ############################# 260 | function bashunit::main::cmd_doc() { 261 | if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then 262 | bashunit::console_header::print_doc_help 263 | exit 0 264 | fi 265 | 266 | bashunit::doc::print_asserts "${1:-}" 267 | exit 0 268 | } 269 | 270 | ############################# 271 | # Subcommand: init 272 | ############################# 273 | function bashunit::main::cmd_init() { 274 | if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then 275 | bashunit::console_header::print_init_help 276 | exit 0 277 | fi 278 | 279 | bashunit::init::project "${1:-}" 280 | exit 0 281 | } 282 | 283 | ############################# 284 | # Subcommand: learn 285 | ############################# 286 | function bashunit::main::cmd_learn() { 287 | if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then 288 | bashunit::console_header::print_learn_help 289 | exit 0 290 | fi 291 | 292 | bashunit::learn::start 293 | exit 0 294 | } 295 | 296 | ############################# 297 | # Subcommand: upgrade 298 | ############################# 299 | function bashunit::main::cmd_upgrade() { 300 | if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then 301 | bashunit::console_header::print_upgrade_help 302 | exit 0 303 | fi 304 | 305 | bashunit::upgrade::upgrade 306 | exit 0 307 | } 308 | 309 | ############################# 310 | # Subcommand: assert 311 | ############################# 312 | 313 | # Check if a name corresponds to an assertion function (not a file or command) 314 | function bashunit::main::is_assertion_function() { 315 | local name="$1" 316 | declare -F "assert_$name" &>/dev/null || declare -F "$name" &>/dev/null 317 | } 318 | 319 | # Check if assertion operates on exit codes 320 | function bashunit::main::is_exit_code_assertion() { 321 | local name="$1" 322 | case "$name" in 323 | exit_code|successful_code|unsuccessful_code|general_error|command_not_found) 324 | return 0 325 | ;; 326 | *) 327 | return 1 328 | ;; 329 | esac 330 | } 331 | 332 | function bashunit::main::cmd_assert() { 333 | if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then 334 | bashunit::console_header::print_assert_help 335 | exit 0 336 | fi 337 | 338 | local first_arg="${1:-}" 339 | if [[ -z "$first_arg" ]]; then 340 | printf "%sError: Assert function name or command is required.%s\n" \ 341 | "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 342 | bashunit::console_header::print_assert_help 343 | exit 1 344 | fi 345 | 346 | # Disable strict mode for assert execution 347 | set +euo pipefail 348 | 349 | # Route to appropriate handler based on first argument 350 | if bashunit::main::is_assertion_function "$first_arg"; then 351 | # Old single-assertion syntax: bashunit assert 352 | local assert_fn="$first_arg" 353 | shift 354 | bashunit::main::exec_assert "$assert_fn" "$@" 355 | elif [[ $# -ge 2 ]] && bashunit::main::is_assertion_function "$2"; then 356 | # New multi-assertion syntax: bashunit assert "" ... 357 | # Detected by: first arg is not assertion, but second arg is an assertion name 358 | bashunit::main::exec_multi_assert "$@" 359 | else 360 | # Fallback: try as single assertion (may fail with function not found) 361 | bashunit::main::exec_assert "$@" 362 | fi 363 | exit $? 364 | } 365 | 366 | ############################# 367 | # Test execution 368 | ############################# 369 | function bashunit::main::exec_tests() { 370 | local filter=$1 371 | local files=("${@:2}") 372 | 373 | local test_files=() 374 | while IFS= read -r line; do 375 | test_files+=("$line") 376 | done < <(bashunit::helper::load_test_files "$filter" "${files[@]}") 377 | 378 | bashunit::internal_log "exec_tests" "filter:$filter" "files:${test_files[*]}" 379 | 380 | if [[ ${#test_files[@]} -eq 0 || -z "${test_files[0]}" ]]; then 381 | printf "%sError: At least one file path is required.%s\n" "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 382 | bashunit::console_header::print_help 383 | exit 1 384 | fi 385 | 386 | # Trap SIGINT (Ctrl-C) and call the cleanup function 387 | trap 'bashunit::main::cleanup' SIGINT 388 | trap '[[ $? -eq $EXIT_CODE_STOP_ON_FAILURE ]] && bashunit::main::handle_stop_on_failure_sync' EXIT 389 | 390 | if bashunit::env::is_parallel_run_enabled && ! bashunit::parallel::is_enabled; then 391 | printf "%sWarning: Parallel tests are supported on macOS, Ubuntu and Windows.\n" "${_BASHUNIT_COLOR_INCOMPLETE}" 392 | printf "For other OS (like Alpine), --parallel is not enabled due to inconsistent results,\n" 393 | printf "particularly involving race conditions.%s " "${_BASHUNIT_COLOR_DEFAULT}" 394 | printf "%sFallback using --no-parallel%s\n" "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" 395 | fi 396 | 397 | if bashunit::parallel::is_enabled; then 398 | bashunit::parallel::init 399 | fi 400 | 401 | bashunit::console_header::print_version_with_env "$filter" "${test_files[@]}" 402 | 403 | if bashunit::env::is_verbose_enabled; then 404 | if bashunit::env::is_simple_output_enabled; then 405 | echo "" 406 | fi 407 | printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '#' 408 | printf "%s\n" "Filter: ${filter:-None}" 409 | printf "%s\n" "Total files: ${#test_files[@]}" 410 | printf "%s\n" "Test files:" 411 | printf -- "- %s\n" "${test_files[@]}" 412 | printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '.' 413 | bashunit::env::print_verbose 414 | printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '#' 415 | fi 416 | 417 | bashunit::runner::load_test_files "$filter" "${test_files[@]}" 418 | 419 | if bashunit::parallel::is_enabled; then 420 | wait 421 | fi 422 | 423 | if bashunit::parallel::is_enabled && bashunit::parallel::must_stop_on_failure; then 424 | printf "\r%sStop on failure enabled...%s\n" "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" 425 | fi 426 | 427 | bashunit::console_results::print_failing_tests_and_reset 428 | bashunit::console_results::print_incomplete_tests_and_reset 429 | bashunit::console_results::print_skipped_tests_and_reset 430 | bashunit::console_results::render_result 431 | exit_code=$? 432 | 433 | if [[ -n "$BASHUNIT_LOG_JUNIT" ]]; then 434 | bashunit::reports::generate_junit_xml "$BASHUNIT_LOG_JUNIT" 435 | fi 436 | 437 | if [[ -n "$BASHUNIT_REPORT_HTML" ]]; then 438 | bashunit::reports::generate_report_html "$BASHUNIT_REPORT_HTML" 439 | fi 440 | 441 | if bashunit::parallel::is_enabled; then 442 | bashunit::parallel::cleanup 443 | fi 444 | 445 | bashunit::internal_log "Finished tests" "exit_code:$exit_code" 446 | exit $exit_code 447 | } 448 | 449 | function bashunit::main::exec_benchmarks() { 450 | local filter=$1 451 | local files=("${@:2}") 452 | 453 | local bench_files=() 454 | while IFS= read -r line; do 455 | bench_files+=("$line") 456 | done < <(bashunit::helper::load_bench_files "$filter" "${files[@]}") 457 | 458 | bashunit::internal_log "exec_benchmarks" "filter:$filter" "files:${bench_files[*]}" 459 | 460 | if [[ ${#bench_files[@]} -eq 0 || -z "${bench_files[0]}" ]]; then 461 | printf "%sError: At least one file path is required.%s\n" "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 462 | bashunit::console_header::print_help 463 | exit 1 464 | fi 465 | 466 | bashunit::console_header::print_version_with_env "$filter" "${bench_files[@]}" 467 | 468 | bashunit::runner::load_bench_files "$filter" "${bench_files[@]}" 469 | 470 | bashunit::benchmark::print_results 471 | 472 | bashunit::internal_log "Finished benchmarks" 473 | } 474 | 475 | function bashunit::main::cleanup() { 476 | printf "%sCaught Ctrl-C, killing all child processes...%s\n" \ 477 | "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" 478 | # Kill all child processes of this script 479 | pkill -P $$ 480 | bashunit::cleanup_script_temp_files 481 | if bashunit::parallel::is_enabled; then 482 | bashunit::parallel::cleanup 483 | fi 484 | exit 1 485 | } 486 | 487 | function bashunit::main::handle_stop_on_failure_sync() { 488 | printf "\n%sStop on failure enabled...%s\n" "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" 489 | bashunit::console_results::print_failing_tests_and_reset 490 | bashunit::console_results::print_incomplete_tests_and_reset 491 | bashunit::console_results::print_skipped_tests_and_reset 492 | bashunit::console_results::render_result 493 | bashunit::cleanup_script_temp_files 494 | if bashunit::parallel::is_enabled; then 495 | bashunit::parallel::cleanup 496 | fi 497 | exit 1 498 | } 499 | 500 | function bashunit::main::exec_assert() { 501 | local original_assert_fn=$1 502 | local args=("${@:2}") 503 | 504 | local assert_fn=$original_assert_fn 505 | 506 | # Check if the function exists 507 | if ! type "$assert_fn" > /dev/null 2>&1; then 508 | assert_fn="assert_$assert_fn" 509 | if ! type "$assert_fn" > /dev/null 2>&1; then 510 | echo "Function $original_assert_fn does not exist." 1>&2 511 | exit 127 512 | fi 513 | fi 514 | 515 | # Get the last argument safely by calculating the array length 516 | local last_index=$((${#args[@]} - 1)) 517 | local last_arg="${args[$last_index]}" 518 | local output="" 519 | local inner_exit_code=0 520 | local bashunit_exit_code=0 521 | 522 | # Handle different assert_* functions 523 | case "$assert_fn" in 524 | assert_exit_code) 525 | output=$(bashunit::main::handle_assert_exit_code "$last_arg") 526 | inner_exit_code=$? 527 | # Remove the last argument and append the exit code 528 | args=("${args[@]:0:last_index}") 529 | args+=("$inner_exit_code") 530 | ;; 531 | *) 532 | # Add more cases here for other assert_* handlers if needed 533 | ;; 534 | esac 535 | 536 | if [[ -n "$output" ]]; then 537 | echo "$output" 1>&1 538 | assert_fn="assert_same" 539 | fi 540 | 541 | # Set a friendly test title for CLI assert command output 542 | bashunit::state::set_test_title "assert ${original_assert_fn#assert_}" 543 | 544 | # Run the assertion function and write into stderr 545 | "$assert_fn" "${args[@]}" 1>&2 546 | bashunit_exit_code=$? 547 | 548 | if [[ "$(bashunit::state::get_tests_failed)" -gt 0 ]] || [[ "$(bashunit::state::get_assertions_failed)" -gt 0 ]]; then 549 | return 1 550 | fi 551 | 552 | return "$bashunit_exit_code" 553 | } 554 | 555 | function bashunit::main::handle_assert_exit_code() { 556 | local cmd="$1" 557 | local output 558 | local inner_exit_code=0 559 | 560 | if [[ $(command -v "${cmd%% *}") ]]; then 561 | output=$(eval "$cmd" 2>&1 || echo "inner_exit_code:$?") 562 | local last_line 563 | last_line=$(echo "$output" | tail -n 1) 564 | if echo "$last_line" | grep -q 'inner_exit_code:[0-9]*'; then 565 | inner_exit_code=$(echo "$last_line" | grep -o 'inner_exit_code:[0-9]*' | cut -d':' -f2) 566 | if ! [[ $inner_exit_code =~ ^[0-9]+$ ]]; then 567 | inner_exit_code=1 568 | fi 569 | output=$(echo "$output" | sed '$d') 570 | fi 571 | echo "$output" 572 | return "$inner_exit_code" 573 | else 574 | echo "Command not found: $cmd" 1>&2 575 | return 127 576 | fi 577 | } 578 | 579 | # Execute multiple assertions on a single command output 580 | # Usage: exec_multi_assert "command" assertion1 arg1 [assertion2 arg2 ...] 581 | function bashunit::main::exec_multi_assert() { 582 | local cmd="$1" 583 | shift 584 | 585 | # Require at least one assertion 586 | if [[ $# -lt 1 ]]; then 587 | printf "%sError: Multi-assertion mode requires at least one assertion.%s\n" \ 588 | "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2 589 | printf "Usage: bashunit assert \"\" [ ...]\n" 1>&2 590 | return 1 591 | fi 592 | 593 | # Check that assertions come in pairs (assertion + arg) 594 | if [[ $# -lt 2 ]] || [[ $(($# % 2)) -ne 0 ]]; then 595 | local assertion_name="${1:-}" 596 | printf "%sError: Missing argument for assertion '%s'.%s\n" \ 597 | "${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2 598 | return 1 599 | fi 600 | 601 | # Execute command and capture output + exit code 602 | local stdout 603 | local cmd_exit_code 604 | stdout=$(eval "$cmd" 2>&1) 605 | cmd_exit_code=$? 606 | 607 | # Print stdout for user visibility 608 | if [[ -n "$stdout" ]]; then 609 | echo "$stdout" 1>&1 610 | fi 611 | 612 | # Parse and execute assertions in pairs 613 | local overall_result=0 614 | while [[ $# -gt 0 ]]; do 615 | local assertion_name="$1" 616 | local assertion_arg="${2:-}" 617 | 618 | if [[ -z "$assertion_arg" ]]; then 619 | printf "%sError: Missing argument for assertion '%s'.%s\n" \ 620 | "${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2 621 | return 1 622 | fi 623 | 624 | shift 2 625 | 626 | # Resolve assertion function name 627 | local assert_fn="$assertion_name" 628 | if ! type "$assert_fn" &>/dev/null; then 629 | assert_fn="assert_$assertion_name" 630 | if ! type "$assert_fn" &>/dev/null; then 631 | printf "%sError: Unknown assertion '%s'.%s\n" \ 632 | "${_BASHUNIT_COLOR_FAILED}" "$assertion_name" "${_BASHUNIT_COLOR_DEFAULT}" 1>&2 633 | return 1 634 | fi 635 | fi 636 | 637 | # Set test title for this assertion 638 | bashunit::state::set_test_title "assert ${assertion_name#assert_}" 639 | 640 | # Execute assertion with appropriate argument 641 | if bashunit::main::is_exit_code_assertion "$assertion_name"; then 642 | # Exit code assertion: pass expected value and captured exit code 643 | "$assert_fn" "$assertion_arg" "" "$cmd_exit_code" 1>&2 644 | else 645 | # Output assertion: pass expected value and captured stdout 646 | "$assert_fn" "$assertion_arg" "$stdout" 1>&2 647 | fi 648 | 649 | if [[ "$(bashunit::state::get_assertions_failed)" -gt 0 ]]; then 650 | overall_result=1 651 | fi 652 | done 653 | 654 | return $overall_result 655 | } 656 | -------------------------------------------------------------------------------- /src/assert.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Helper to mark assertion as failed and set the guard flag 4 | function bashunit::assert::mark_failed() { 5 | bashunit::state::add_assertions_failed 6 | bashunit::state::mark_assertion_failed_in_test 7 | } 8 | 9 | # Guard clause to skip assertion if one already failed in test (when stop-on-assertion is enabled) 10 | function bashunit::assert::should_skip() { 11 | bashunit::env::is_stop_on_assertion_failure_enabled && (( _BASHUNIT_ASSERTION_FAILED_IN_TEST )) 12 | } 13 | 14 | function bashunit::fail() { 15 | bashunit::assert::should_skip && return 0 16 | 17 | local message="${1:-${FUNCNAME[1]}}" 18 | 19 | local test_fn 20 | test_fn="$(bashunit::helper::find_test_function_name)" 21 | local label 22 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 23 | bashunit::assert::mark_failed 24 | bashunit::console_results::print_failure_message "${label}" "$message" 25 | } 26 | 27 | function assert_true() { 28 | bashunit::assert::should_skip && return 0 29 | 30 | local actual="$1" 31 | 32 | # Check for expected literal values first 33 | case "$actual" in 34 | "true"|"0") bashunit::state::add_assertions_passed; return ;; 35 | "false"|"1") bashunit::handle_bool_assertion_failure "true or 0" "$actual"; return ;; 36 | esac 37 | 38 | # Run command or eval and check the exit code 39 | bashunit::run_command_or_eval "$actual" 40 | local exit_code=$? 41 | 42 | if [[ $exit_code -ne 0 ]]; then 43 | bashunit::handle_bool_assertion_failure "command or function with zero exit code" "exit code: $exit_code" 44 | else 45 | bashunit::state::add_assertions_passed 46 | fi 47 | } 48 | 49 | function assert_false() { 50 | bashunit::assert::should_skip && return 0 51 | 52 | local actual="$1" 53 | 54 | # Check for expected literal values first 55 | case "$actual" in 56 | "false"|"1") bashunit::state::add_assertions_passed; return ;; 57 | "true"|"0") bashunit::handle_bool_assertion_failure "false or 1" "$actual"; return ;; 58 | esac 59 | 60 | # Run command or eval and check the exit code 61 | bashunit::run_command_or_eval "$actual" 62 | local exit_code=$? 63 | 64 | if [[ $exit_code -eq 0 ]]; then 65 | bashunit::handle_bool_assertion_failure "command or function with non-zero exit code" "exit code: $exit_code" 66 | else 67 | bashunit::state::add_assertions_passed 68 | fi 69 | } 70 | 71 | function bashunit::run_command_or_eval() { 72 | local cmd="$1" 73 | 74 | if [[ "$cmd" =~ ^eval ]]; then 75 | eval "${cmd#eval }" &> /dev/null 76 | elif [[ "$(command -v "$cmd")" =~ ^alias ]]; then 77 | eval "$cmd" &> /dev/null 78 | else 79 | "$cmd" &> /dev/null 80 | fi 81 | return $? 82 | } 83 | 84 | function bashunit::handle_bool_assertion_failure() { 85 | local expected="$1" 86 | local got="$2" 87 | local test_fn 88 | test_fn="$(bashunit::helper::find_test_function_name)" 89 | local label 90 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 91 | 92 | bashunit::assert::mark_failed 93 | bashunit::console_results::print_failed_test "$label" "$expected" "but got " "$got" 94 | } 95 | 96 | function assert_same() { 97 | bashunit::assert::should_skip && return 0 98 | 99 | local expected="$1" 100 | local actual="$2" 101 | 102 | if [[ "$expected" != "$actual" ]]; then 103 | local test_fn 104 | test_fn="$(bashunit::helper::find_test_function_name)" 105 | local label 106 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 107 | bashunit::assert::mark_failed 108 | bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${actual}" 109 | return 110 | fi 111 | 112 | bashunit::state::add_assertions_passed 113 | } 114 | 115 | function assert_equals() { 116 | bashunit::assert::should_skip && return 0 117 | 118 | local expected="$1" 119 | local actual="$2" 120 | 121 | local actual_cleaned 122 | actual_cleaned=$(bashunit::str::strip_ansi "$actual") 123 | local expected_cleaned 124 | expected_cleaned=$(bashunit::str::strip_ansi "$expected") 125 | 126 | if [[ "$expected_cleaned" != "$actual_cleaned" ]]; then 127 | local test_fn 128 | test_fn="$(bashunit::helper::find_test_function_name)" 129 | local label 130 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 131 | bashunit::assert::mark_failed 132 | bashunit::console_results::print_failed_test "${label}" "${expected_cleaned}" "but got " "${actual_cleaned}" 133 | return 134 | fi 135 | 136 | bashunit::state::add_assertions_passed 137 | } 138 | 139 | function assert_not_equals() { 140 | bashunit::assert::should_skip && return 0 141 | 142 | local expected="$1" 143 | local actual="$2" 144 | 145 | local actual_cleaned 146 | actual_cleaned=$(bashunit::str::strip_ansi "$actual") 147 | local expected_cleaned 148 | expected_cleaned=$(bashunit::str::strip_ansi "$expected") 149 | 150 | if [[ "$expected_cleaned" == "$actual_cleaned" ]]; then 151 | local test_fn 152 | test_fn="$(bashunit::helper::find_test_function_name)" 153 | local label 154 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 155 | bashunit::assert::mark_failed 156 | bashunit::console_results::print_failed_test "${label}" "${expected_cleaned}" "but got " "${actual_cleaned}" 157 | return 158 | fi 159 | 160 | bashunit::state::add_assertions_passed 161 | } 162 | 163 | function assert_empty() { 164 | bashunit::assert::should_skip && return 0 165 | 166 | local expected="$1" 167 | 168 | if [[ "$expected" != "" ]]; then 169 | local test_fn 170 | test_fn="$(bashunit::helper::find_test_function_name)" 171 | local label 172 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 173 | bashunit::assert::mark_failed 174 | bashunit::console_results::print_failed_test "${label}" "to be empty" "but got " "${expected}" 175 | return 176 | fi 177 | 178 | bashunit::state::add_assertions_passed 179 | } 180 | 181 | function assert_not_empty() { 182 | bashunit::assert::should_skip && return 0 183 | 184 | local expected="$1" 185 | 186 | if [[ "$expected" == "" ]]; then 187 | local test_fn 188 | test_fn="$(bashunit::helper::find_test_function_name)" 189 | local label 190 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 191 | bashunit::assert::mark_failed 192 | bashunit::console_results::print_failed_test "${label}" "to not be empty" "but got " "${expected}" 193 | return 194 | fi 195 | 196 | bashunit::state::add_assertions_passed 197 | } 198 | 199 | function assert_not_same() { 200 | bashunit::assert::should_skip && return 0 201 | 202 | local expected="$1" 203 | local actual="$2" 204 | 205 | if [[ "$expected" == "$actual" ]]; then 206 | local test_fn 207 | test_fn="$(bashunit::helper::find_test_function_name)" 208 | local label 209 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 210 | bashunit::assert::mark_failed 211 | bashunit::console_results::print_failed_test "${label}" "${expected}" "but got " "${actual}" 212 | return 213 | fi 214 | 215 | bashunit::state::add_assertions_passed 216 | } 217 | 218 | function assert_contains() { 219 | bashunit::assert::should_skip && return 0 220 | 221 | local expected="$1" 222 | local actual_arr=("${@:2}") 223 | local actual 224 | actual=$(printf '%s\n' "${actual_arr[@]}") 225 | 226 | if ! [[ $actual == *"$expected"* ]]; then 227 | local test_fn 228 | test_fn="$(bashunit::helper::find_test_function_name)" 229 | local label 230 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 231 | bashunit::assert::mark_failed 232 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}" 233 | return 234 | fi 235 | 236 | bashunit::state::add_assertions_passed 237 | } 238 | 239 | function assert_contains_ignore_case() { 240 | bashunit::assert::should_skip && return 0 241 | 242 | local expected="$1" 243 | local actual="$2" 244 | 245 | shopt -s nocasematch 246 | 247 | if ! [[ $actual =~ $expected ]]; then 248 | local test_fn 249 | test_fn="$(bashunit::helper::find_test_function_name)" 250 | local label 251 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 252 | bashunit::assert::mark_failed 253 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}" 254 | shopt -u nocasematch 255 | return 256 | fi 257 | 258 | shopt -u nocasematch 259 | bashunit::state::add_assertions_passed 260 | } 261 | 262 | function assert_not_contains() { 263 | bashunit::assert::should_skip && return 0 264 | 265 | local expected="$1" 266 | local actual_arr=("${@:2}") 267 | local actual 268 | actual=$(printf '%s\n' "${actual_arr[@]}") 269 | 270 | if [[ $actual == *"$expected"* ]]; then 271 | local test_fn 272 | test_fn="$(bashunit::helper::find_test_function_name)" 273 | local label 274 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 275 | bashunit::assert::mark_failed 276 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to not contain" "${expected}" 277 | return 278 | fi 279 | 280 | bashunit::state::add_assertions_passed 281 | } 282 | 283 | function assert_matches() { 284 | bashunit::assert::should_skip && return 0 285 | 286 | local expected="$1" 287 | local actual_arr=("${@:2}") 288 | local actual 289 | actual=$(printf '%s\n' "${actual_arr[@]}") 290 | 291 | if ! [[ $actual =~ $expected ]]; then 292 | local test_fn 293 | test_fn="$(bashunit::helper::find_test_function_name)" 294 | local label 295 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 296 | bashunit::assert::mark_failed 297 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to match" "${expected}" 298 | return 299 | fi 300 | 301 | bashunit::state::add_assertions_passed 302 | } 303 | 304 | function assert_not_matches() { 305 | bashunit::assert::should_skip && return 0 306 | 307 | local expected="$1" 308 | local actual_arr=("${@:2}") 309 | local actual 310 | actual=$(printf '%s\n' "${actual_arr[@]}") 311 | 312 | if [[ $actual =~ $expected ]]; then 313 | local test_fn 314 | test_fn="$(bashunit::helper::find_test_function_name)" 315 | local label 316 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 317 | bashunit::assert::mark_failed 318 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to not match" "${expected}" 319 | return 320 | fi 321 | 322 | bashunit::state::add_assertions_passed 323 | } 324 | 325 | function assert_exec() { 326 | bashunit::assert::should_skip && return 0 327 | 328 | local cmd="$1" 329 | shift 330 | 331 | local expected_exit=0 332 | local expected_stdout="" 333 | local expected_stderr="" 334 | local check_stdout=false 335 | local check_stderr=false 336 | 337 | while [[ $# -gt 0 ]]; do 338 | case "$1" in 339 | --exit) 340 | expected_exit="$2" 341 | shift 2 342 | ;; 343 | --stdout) 344 | expected_stdout="$2" 345 | check_stdout=true 346 | shift 2 347 | ;; 348 | --stderr) 349 | expected_stderr="$2" 350 | check_stderr=true 351 | shift 2 352 | ;; 353 | *) 354 | shift 355 | ;; 356 | esac 357 | done 358 | 359 | local stdout_file stderr_file 360 | stdout_file=$(mktemp) 361 | stderr_file=$(mktemp) 362 | 363 | eval "$cmd" >"$stdout_file" 2>"$stderr_file" 364 | local exit_code=$? 365 | 366 | local stdout 367 | stdout=$(cat "$stdout_file") 368 | local stderr 369 | stderr=$(cat "$stderr_file") 370 | 371 | rm -f "$stdout_file" "$stderr_file" 372 | 373 | local expected_desc="exit: $expected_exit" 374 | local actual_desc="exit: $exit_code" 375 | local failed=0 376 | 377 | if [[ "$exit_code" -ne "$expected_exit" ]]; then 378 | failed=1 379 | fi 380 | 381 | if $check_stdout; then 382 | expected_desc+=$'\n'"stdout: $expected_stdout" 383 | actual_desc+=$'\n'"stdout: $stdout" 384 | if [[ "$stdout" != "$expected_stdout" ]]; then 385 | failed=1 386 | fi 387 | fi 388 | 389 | if $check_stderr; then 390 | expected_desc+=$'\n'"stderr: $expected_stderr" 391 | actual_desc+=$'\n'"stderr: $stderr" 392 | if [[ "$stderr" != "$expected_stderr" ]]; then 393 | failed=1 394 | fi 395 | fi 396 | 397 | if [[ $failed -eq 1 ]]; then 398 | local test_fn 399 | test_fn="$(bashunit::helper::find_test_function_name)" 400 | local label 401 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 402 | bashunit::assert::mark_failed 403 | bashunit::console_results::print_failed_test "$label" "$expected_desc" "but got " "$actual_desc" 404 | return 405 | fi 406 | 407 | bashunit::state::add_assertions_passed 408 | } 409 | 410 | function assert_exit_code() { 411 | local actual_exit_code=${3-"$?"} # Capture $? before guard check 412 | bashunit::assert::should_skip && return 0 413 | 414 | local expected_exit_code="$1" 415 | 416 | if [[ "$actual_exit_code" -ne "$expected_exit_code" ]]; then 417 | local test_fn 418 | test_fn="$(bashunit::helper::find_test_function_name)" 419 | local label 420 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 421 | bashunit::assert::mark_failed 422 | bashunit::console_results::print_failed_test "${label}" "${actual_exit_code}" "to be" "${expected_exit_code}" 423 | return 424 | fi 425 | 426 | bashunit::state::add_assertions_passed 427 | } 428 | 429 | function assert_successful_code() { 430 | local actual_exit_code=${3-"$?"} # Capture $? before guard check 431 | bashunit::assert::should_skip && return 0 432 | 433 | local expected_exit_code=0 434 | 435 | if [[ "$actual_exit_code" -ne "$expected_exit_code" ]]; then 436 | local test_fn 437 | test_fn="$(bashunit::helper::find_test_function_name)" 438 | local label 439 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 440 | bashunit::assert::mark_failed 441 | bashunit::console_results::print_failed_test \ 442 | "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" 443 | return 444 | fi 445 | 446 | bashunit::state::add_assertions_passed 447 | } 448 | 449 | function assert_unsuccessful_code() { 450 | local actual_exit_code=${3-"$?"} # Capture $? before guard check 451 | bashunit::assert::should_skip && return 0 452 | 453 | if [[ "$actual_exit_code" -eq 0 ]]; then 454 | local test_fn 455 | test_fn="$(bashunit::helper::find_test_function_name)" 456 | local label 457 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 458 | bashunit::assert::mark_failed 459 | bashunit::console_results::print_failed_test "${label}" "${actual_exit_code}" "to be non-zero" "but was 0" 460 | return 461 | fi 462 | 463 | bashunit::state::add_assertions_passed 464 | } 465 | 466 | function assert_general_error() { 467 | local actual_exit_code=${3-"$?"} # Capture $? before guard check 468 | bashunit::assert::should_skip && return 0 469 | 470 | local expected_exit_code=1 471 | 472 | if [[ "$actual_exit_code" -ne "$expected_exit_code" ]]; then 473 | local test_fn 474 | test_fn="$(bashunit::helper::find_test_function_name)" 475 | local label 476 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 477 | bashunit::assert::mark_failed 478 | bashunit::console_results::print_failed_test \ 479 | "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" 480 | return 481 | fi 482 | 483 | bashunit::state::add_assertions_passed 484 | } 485 | 486 | function assert_command_not_found() { 487 | local actual_exit_code=${3-"$?"} # Capture $? before guard check 488 | bashunit::assert::should_skip && return 0 489 | 490 | local expected_exit_code=127 491 | 492 | if [[ $actual_exit_code -ne "$expected_exit_code" ]]; then 493 | local test_fn 494 | test_fn="$(bashunit::helper::find_test_function_name)" 495 | local label 496 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 497 | bashunit::assert::mark_failed 498 | bashunit::console_results::print_failed_test \ 499 | "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" 500 | return 501 | fi 502 | 503 | bashunit::state::add_assertions_passed 504 | } 505 | 506 | function assert_string_starts_with() { 507 | bashunit::assert::should_skip && return 0 508 | 509 | local expected="$1" 510 | local actual_arr=("${@:2}") 511 | local actual 512 | actual=$(printf '%s\n' "${actual_arr[@]}") 513 | 514 | if [[ $actual != "$expected"* ]]; then 515 | local test_fn 516 | test_fn="$(bashunit::helper::find_test_function_name)" 517 | local label 518 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 519 | bashunit::assert::mark_failed 520 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to start with" "${expected}" 521 | return 522 | fi 523 | 524 | bashunit::state::add_assertions_passed 525 | } 526 | 527 | function assert_string_not_starts_with() { 528 | bashunit::assert::should_skip && return 0 529 | 530 | local expected="$1" 531 | local actual="$2" 532 | 533 | if [[ $actual == "$expected"* ]]; then 534 | local test_fn 535 | test_fn="$(bashunit::helper::find_test_function_name)" 536 | local label 537 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 538 | bashunit::assert::mark_failed 539 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to not start with" "${expected}" 540 | return 541 | fi 542 | 543 | bashunit::state::add_assertions_passed 544 | } 545 | 546 | function assert_string_ends_with() { 547 | bashunit::assert::should_skip && return 0 548 | 549 | local expected="$1" 550 | local actual_arr=("${@:2}") 551 | local actual 552 | actual=$(printf '%s\n' "${actual_arr[@]}") 553 | 554 | if [[ $actual != *"$expected" ]]; then 555 | local test_fn 556 | test_fn="$(bashunit::helper::find_test_function_name)" 557 | local label 558 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 559 | bashunit::assert::mark_failed 560 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to end with" "${expected}" 561 | return 562 | fi 563 | 564 | bashunit::state::add_assertions_passed 565 | } 566 | 567 | function assert_string_not_ends_with() { 568 | bashunit::assert::should_skip && return 0 569 | 570 | local expected="$1" 571 | local actual_arr=("${@:2}") 572 | local actual 573 | actual=$(printf '%s\n' "${actual_arr[@]}") 574 | 575 | if [[ $actual == *"$expected" ]]; then 576 | local test_fn 577 | test_fn="$(bashunit::helper::find_test_function_name)" 578 | local label 579 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 580 | bashunit::assert::mark_failed 581 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to not end with" "${expected}" 582 | return 583 | fi 584 | 585 | bashunit::state::add_assertions_passed 586 | } 587 | 588 | function assert_less_than() { 589 | bashunit::assert::should_skip && return 0 590 | 591 | local expected="$1" 592 | local actual="$2" 593 | 594 | if ! [[ "$actual" -lt "$expected" ]]; then 595 | local test_fn 596 | test_fn="$(bashunit::helper::find_test_function_name)" 597 | local label 598 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 599 | bashunit::assert::mark_failed 600 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to be less than" "${expected}" 601 | return 602 | fi 603 | 604 | bashunit::state::add_assertions_passed 605 | } 606 | 607 | function assert_less_or_equal_than() { 608 | bashunit::assert::should_skip && return 0 609 | 610 | local expected="$1" 611 | local actual="$2" 612 | 613 | if ! [[ "$actual" -le "$expected" ]]; then 614 | local test_fn 615 | test_fn="$(bashunit::helper::find_test_function_name)" 616 | local label 617 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 618 | bashunit::assert::mark_failed 619 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to be less or equal than" "${expected}" 620 | return 621 | fi 622 | 623 | bashunit::state::add_assertions_passed 624 | } 625 | 626 | function assert_greater_than() { 627 | bashunit::assert::should_skip && return 0 628 | 629 | local expected="$1" 630 | local actual="$2" 631 | 632 | if ! [[ "$actual" -gt "$expected" ]]; then 633 | local test_fn 634 | test_fn="$(bashunit::helper::find_test_function_name)" 635 | local label 636 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 637 | bashunit::assert::mark_failed 638 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to be greater than" "${expected}" 639 | return 640 | fi 641 | 642 | bashunit::state::add_assertions_passed 643 | } 644 | 645 | function assert_greater_or_equal_than() { 646 | bashunit::assert::should_skip && return 0 647 | 648 | local expected="$1" 649 | local actual="$2" 650 | 651 | if ! [[ "$actual" -ge "$expected" ]]; then 652 | local test_fn 653 | test_fn="$(bashunit::helper::find_test_function_name)" 654 | local label 655 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 656 | bashunit::assert::mark_failed 657 | bashunit::console_results::print_failed_test "${label}" "${actual}" "to be greater or equal than" "${expected}" 658 | return 659 | fi 660 | 661 | bashunit::state::add_assertions_passed 662 | } 663 | 664 | function assert_line_count() { 665 | bashunit::assert::should_skip && return 0 666 | 667 | local expected="$1" 668 | local input_arr=("${@:2}") 669 | local input_str 670 | input_str=$(printf '%s\n' "${input_arr[@]}") 671 | 672 | if [ -z "$input_str" ]; then 673 | local actual=0 674 | else 675 | local actual 676 | actual=$(echo "$input_str" | wc -l | tr -d '[:blank:]') 677 | local additional_new_lines 678 | additional_new_lines=$(grep -o '\\n' <<< "$input_str" | wc -l | tr -d '[:blank:]') 679 | ((actual+=additional_new_lines)) 680 | fi 681 | 682 | if [[ "$expected" != "$actual" ]]; then 683 | local test_fn 684 | test_fn="$(bashunit::helper::find_test_function_name)" 685 | local label 686 | label="$(bashunit::helper::normalize_test_function_name "$test_fn")" 687 | 688 | bashunit::assert::mark_failed 689 | bashunit::console_results::print_failed_test "${label}" "${input_str}"\ 690 | "to contain number of lines equal to" "${expected}"\ 691 | "but found" "${actual}" 692 | return 693 | fi 694 | 695 | bashunit::state::add_assertions_passed 696 | } 697 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ### Added 6 | - Display execution time in minutes format when tests run over 60 seconds (e.g., "2m 1s") 7 | - Display individual test duration in human-readable format (ms, s, or m s) instead of always milliseconds 8 | - Add `--failures-only` flag to suppress passed/skipped/incomplete tests and show only failures 9 | - Add `--no-color` flag to disable ANSI color output (also supports `NO_COLOR` env var per no-color.org standard) 10 | - Add multiple assertions support in standalone mode: `bashunit assert "cmd" exit_code "1" contains "error"` 11 | 12 | ### Changed 13 | - **BREAKING:** Rename `--preserve-env` flag to `--skip-env-file` for clearer semantics 14 | - **BREAKING:** Rename `BASHUNIT_PRESERVE_ENV` environment variable to `BASHUNIT_SKIP_ENV_FILE` 15 | - Improve documentation for `--skip-env-file` clarifying that shell functions are not inherited 16 | - Add tip to Bootstrap documentation promoting it as the solution for function availability in tests 17 | - Clarify that exit code assertions check $? instead of executing commands 18 | 19 | ### Fixed 20 | - Internal flaky tests when running `--strict` 21 | - Visible stdout/stderr during normal execution `set_up_before_script` and `tear_down_after_script` 22 | 23 | ## [0.29.0](https://github.com/TypedDevs/bashunit/compare/0.28.0...0.29.0) - 2025-12-08 24 | 25 | ### Added 26 | - Add bootstrap argument passing support via `--env "file.sh arg1 arg2"` or `BASHUNIT_BOOTSTRAP_ARGS` 27 | - Add `--preserve-env` flag to skip `.env` loading and use shell environment only 28 | - Add `-l, --login` flag to run tests in login shell context 29 | - Add `--strict` flag to enable strict shell mode (`set -euo pipefail`) for tests 30 | - Add `BASHUNIT_STRICT_MODE` configuration option (default: `false`) 31 | - Add `-R, --run-all` flag to run all assertions even when one fails 32 | - Add `BASHUNIT_STOP_ON_ASSERTION_FAILURE` configuration option (default: `true`) 33 | 34 | ### Changed 35 | - **BREAKING:** Namespace all internal functions and variables to prevent collisions with user code 36 | - All helper functions now use `bashunit::` prefix (e.g., `skip` → `bashunit::skip`) 37 | - All internal functions now use `bashunit::` prefix (e.g., `helper::trim` → `bashunit::helper::trim`) 38 | - All internal variables now use `_BASHUNIT_` prefix (e.g., `_TESTS_PASSED` → `_BASHUNIT_TESTS_PASSED`) 39 | - All `assert_*` functions remain unchanged (public API) 40 | 41 | ### Fixed 42 | - Improve `assert` command output: show `assert ` instead of internal function name in failure messages 43 | - Custom assertions now display the correct test function name in failure messages 44 | - Data providers now work when `set_up_before_script` changes directory 45 | - Subsequent test files now run when `set_up_before_script` changes directory 46 | - Catch intermediate failing commands in `set_up_before_script` and `tear_down_after_script` 47 | 48 | ## [0.28.0](https://github.com/TypedDevs/bashunit/compare/0.27.0...0.28.0) - 2025-12-01 49 | 50 | ### Added 51 | - Add inline filter syntax to run specific tests from a file 52 | - `path::function_name` - filter tests by function name 53 | - `path:line_number` - run the test containing the specified line 54 | - Add `--show-skipped` and `--show-incomplete` options to display skipped/incomplete tests at the end 55 | 56 | ### Changed 57 | - **BREAKING:** Introduce subcommand-based CLI architecture 58 | - `bashunit test [path]` - run tests (default, backwards compatible with `bashunit [path]`) 59 | - `bashunit bench [path]` - run benchmarks (replaces `--bench`) 60 | - `bashunit doc [filter]` - show assertion docs (replaces `--doc`) 61 | - `bashunit init [dir]` - initialize project (replaces `--init`) 62 | - `bashunit learn` - interactive tutorial (replaces `--learn`) 63 | - `bashunit upgrade` - upgrade to latest (replaces `--upgrade`) 64 | - **BREAKING:** Tests now stop at first assertion failure within a test function 65 | - Matches PHPUnit and Jest default behavior 66 | - Subsequent assertions in the same test are skipped after a failure 67 | - Other test functions continue to run normally 68 | 69 | ### Fixed 70 | - Stop executing remaining commands in `set_up`/`tear_down` after first failure 71 | - Count all tests as failed when `set_up_before_script` fails 72 | 73 | ### Performance 74 | - Optimize assertion guard: use integer comparison instead of string comparison 75 | 76 | ## [0.27.0](https://github.com/TypedDevs/bashunit/compare/0.26.0...0.27.0) - 2025-11-26 77 | 78 | ### Added 79 | - Add `--learn` interactive tutorial with 10 progressive lessons for hands-on learning 80 | - Add comprehensive "Common Patterns" documentation with real-world testing examples 81 | 82 | ### Fixed 83 | - Pass arguments to mocked functions 84 | - Fix lifecycle hooks not catching intermediate failing commands in `set_up` and `tear_down` 85 | 86 | ### Changed 87 | - Simplify CI: use only `-latest` runners for Ubuntu and macOS, remove deprecated `macos-13` 88 | 89 | ### Performance 90 | - Optimize temp directory creation: initialize once at startup instead of per temp file 91 | - Optimize parallel result aggregation: use bash redirection instead of spawning `tail` subprocess 92 | - Optimize state access: cache state values to avoid repeated subshell invocations 93 | 94 | ## [0.26.0](https://github.com/TypedDevs/bashunit/compare/0.25.0...0.26.0) - 2025-11-02 95 | 96 | - Add `assert_unsuccessful_code` assertion to check for non-zero exit codes 97 | - Fix bench tests missing test_file var 98 | - Fix compatibility with older python versions for clock::now 99 | - Fix `data_set` with arguments containing spaces 100 | - Eliminated redundant `declare -F | awk` calls that were happening for every test/bench file 101 | - Replaced tail and process with Bash parameter expansion 102 | 103 | ## [0.25.0](https://github.com/TypedDevs/bashunit/compare/0.24.0...0.25.0) - 2025-10-05 104 | 105 | - Add AI developer tools integration and guidelines 106 | - Add Project-wide `copilot-instructions.md` 107 | - Add `AGENTS.md` for external developer tools integration 108 | - Add tasks storage policy clarifying `.tasks/` (versioned) vs `.task/` (git-ignored) 109 | - Include `set_test_title` helper in the single-file library 110 | - Fix lifecycle hooks capture-and-report flow errors 111 | - `set_up`, `tear_down`, `set_up_before_script`, `tear_down_after_script` 112 | - Fix false negative from `assert_have_been_called_with` with pipes 113 | - Fix preservation of trailing whitespace in final argument to `data_set` 114 | - Fix unbound variable error in `parse_data_provider_args` with `set -u` 115 | - Fix wrong assertion_failed name of test on failure 116 | - Fix test name interpolation on failure 117 | 118 | ## [0.24.0](https://github.com/TypedDevs/bashunit/compare/0.23.0...0.24.0) - 2025-09-14 119 | 120 | - Improve `assert_have_been_called_with` with strict argument matching 121 | - Make Windows install clearer in the docs by adding an option for Linux/Mac and another one for Windows. 122 | - Add data_set function for empty values and values with spaces/tabs/newlines 123 | - Document workaround for function name collisions when sourcing scripts 124 | - Fix `temp_dir` and `temp_file` data not being cleaned up when created in `set_up_before_script` 125 | - Fix `/tmp/bashunit/parallel` not being cleaned after test run 126 | 127 | ## [0.23.0](https://github.com/TypedDevs/bashunit/compare/0.22.3...0.23.0) - 2025-08-03 128 | 129 | - Update docs mocks usage 130 | - Skip report tracking unless a report output is requested 131 | - Add support for `.bash` test files 132 | - Add runtime check for Bash >= 3.2 133 | - Add fallback for `clock::now` with seconds resolution only 134 | - Add `set_test_title` to allow custom test titles 135 | - Add `assert_exec` to validate exit code, stdout and stderr at once 136 | 137 | ## [0.22.3](https://github.com/TypedDevs/bashunit/compare/0.22.2...0.22.3) - 2025-07-27 138 | 139 | - Fix NixOS support 140 | - Fix parallel and `compgen` 141 | - Use `command -v` instead of `which` 142 | 143 | ## [0.22.2](https://github.com/TypedDevs/bashunit/compare/0.22.1...0.22.2) - 2025-07-26 144 | 145 | - Fix broken core snapshot tests 146 | - Improve NixOS support 147 | - Add line number to failing tests 148 | 149 | ## [0.22.1](https://github.com/TypedDevs/bashunit/compare/0.22.0...0.22.1) - 2025-07-23 150 | 151 | - Fix prevents writing in src dir during tests 152 | - Fix negative widths with rpad 153 | - Fix internal assert_line_count and call_test_functions 154 | - Improve suffix assertion checks to use shell pattern matching 155 | - Include calling function name in dev log output for easier debugging 156 | - Add more internal dev log messages and prefix them with [INTERNAL] 157 | - Toggle internal log messages with `BASHUNIT_INTERNAL_LOG=true|false` 158 | 159 | ## [0.22.0](https://github.com/TypedDevs/bashunit/compare/0.21.0...0.22.0) - 2025-07-20 160 | 161 | - Fix process time always shows as 0 ms 162 | - Fixed terminal width detection first tput and fall back stty 163 | - Refactor clock optimizing the implementation used to get the time 164 | - Add `--init [dir]` option to get you started quickly 165 | - Optimize `--help` message 166 | - Add `--no-output` option 167 | 168 | ## [0.21.0](https://github.com/TypedDevs/bashunit/compare/0.20.0...0.21.0) - 2025-06-18 169 | 170 | - Fix typo "to has been called" 171 | - Add weekly downloads to the docs 172 | - Fix parallel runner 173 | - Count data providers when counting total tests 174 | - Add benchmark feature 175 | - Support placeholder `::ignore::` in snapshots 176 | - Add project overview docs 177 | - Improve clock performance 178 | - Make install.sh args more flexible 179 | - Improve Windows detection allowing parallel tests on Git Bash, MSYS and Cygwin 180 | - Add more CI jobs for different ubuntu and macos versions 181 | 182 | ## [0.20.0](https://github.com/TypedDevs/bashunit/compare/0.19.1...0.20.0) - 2025-06-01 183 | 184 | - Fix asserts on test doubles in subshell 185 | - Allow interpolating arguments in data providers output 186 | - Deprecate `# data_provider` in favor of `# @data_provider` 187 | - Allow `assert_have_been_called_with` to check arguments of specific calls 188 | - Enable parallel tests on Windows 189 | - Add `assert_not_called` 190 | - Improve `helper::find_total_tests` performance 191 | - Added `assert_match_snapshot_ignore_colors` 192 | - Optimize `runner::parse_result_sync` 193 | - Fix `parse_result_parallel` template 194 | 195 | ## [0.19.1](https://github.com/TypedDevs/bashunit/compare/0.19.0...0.19.1) - 2025-05-23 196 | 197 | - Replace `#!/bin/bash` with `#!/usr/bin/env bash` 198 | - Usage printf with awk, which correctly handles float rounding and improves portability 199 | 200 | ## [0.19.0](https://github.com/TypedDevs/bashunit/compare/0.18.0...0.19.0) - 2025-02-19 201 | 202 | - Fixed false negative with `set -e` 203 | - Fixed name rendered when having `test_test_*` 204 | - Fixed duplicate function detection 205 | - Fixed display test with multiple outputs in multiline 206 | - Improved output: adding a space between each test file 207 | - Removed `BASHUNIT_DEV_MODE` in favor of `BASHUNIT_DEV_LOG` 208 | - Added source file and line on global dev function `log` 209 | 210 | ## [0.18.0](https://github.com/TypedDevs/bashunit/compare/0.17.0...0.18.0) - 2024-10-16 211 | 212 | - Added `-p|--parallel` to enable running tests in parallel 213 | - Enabled only in macOS and Ubuntu 214 | - Added `assert_file_contains` and `assert_file_not_contains` 215 | - Added `assert_true` and `assert_false` 216 | - Added `BASHUNIT_DEV_LOG` 217 | - Added global util functions 218 | - current_dir 219 | - current_filename 220 | - caller_filename 221 | - caller_line 222 | - current_timestamp 223 | - is_command_available 224 | - random_str 225 | - temp_file 226 | - temp_dir 227 | - cleanup_temp_files 228 | - log 229 | - Add default env values: 230 | - `BASHUNIT_DEFAULT_PATH="tests"` 231 | - `BASHUNIT_BOOTSTRAP="tests/bootstrap.sh"` 232 | - Add check that git is installed to `install.sh` 233 | - Add `-vvv|--verbose` to display internal details of each test 234 | - Fixed `-S|--stop-on-failure` behaviour 235 | - Improved time taken display 236 | - Improved clean up temporal files and directories 237 | - Improved CI test speed by running them in parallel 238 | - Removed git dependency for stable installations 239 | - Rename option `--verbose` to `--detailed` 240 | - which is the default display behaviour, the opposite as `--simple` 241 | - Added `assert_not_same` 242 | 243 | ## [0.17.0](https://github.com/TypedDevs/bashunit/compare/0.16.0...0.17.0) - 2024-10-01 244 | 245 | - Fixed simple output for non-successful states 246 | - Added support for Alpine (Linux Distro) 247 | - Added optional file-path as 2nd arg to `--debug` option 248 | - Added runtime duration per test 249 | - Added defer expressions with when using standalone assertions 250 | - Added failing tests after running the entire suite 251 | - Improved runtime errors handling 252 | - Simplified total tests display on the header 253 | - Renamed `BASHUNIT_TESTS_ENV` to `BASHUNIT_BOOTSTRAP` 254 | - Better handler runtime errors 255 | - Display failing tests after running the entire suite 256 | - Added defer expressions with `eval` when using standalone assertions 257 | - Fixed simple output for non-successful states 258 | - Remove deprecated assertions 259 | - Some required dependencies now optional: perl, coreutils 260 | - Upgrade and install script can now use `wget` if `curl` is not installed 261 | - Tests can be also be timed by making use of `EPOCHREALTIME` on supported system 262 | - Switch to testing the environment of capabilities 263 | - rather than assuming various operating systems and Linux distributions have programs installed 264 | 265 | ## [0.16.0](https://github.com/TypedDevs/bashunit/compare/0.15.0...0.16.0) - 2024-09-15 266 | 267 | - Fixed `clock::now` can't locate Time when is not available 268 | - Fixed failing tests when `command not found` and `unbound variable` 269 | - Fixed total tests wrong number 270 | - Update GitHub actions installation steps documentation 271 | - Added `assert_files_equals`, `assert_files_not_equals` 272 | - Added `BASHUNIT_TESTS_ENV` 273 | 274 | ## [0.15.0](https://github.com/TypedDevs/bashunit/compare/0.14.0...0.15.0) - 2024-09-01 275 | 276 | - Fixed `--filter|-f` to work with `test_*` matching function name input. 277 | - Added assertions to log file 278 | - Rename the current `assert_equals` to `assert_same` 279 | - Rename `assert_equals_ignore_colors` to `assert_equals` and ignore all special chars 280 | - Data providers support multiple arguments 281 | - Remove `multi-invokers` in favor of `data providers` 282 | - Removing trailing slashes `/` from the test directories naming output. 283 | - Align "Expected" and "but got" on `assert_*` fails message. 284 | - Change `-v` as shortcut for `--version` 285 | - Add `-vvv` as shortcut for `--verbose` 286 | - Fix wrong commit id when installing beta 287 | - Add display total tests upfront when running bashunit 288 | - Add `BASHUNIT_` suffix to all .env config keys 289 | - BASHUNIT_SHOW_HEADER 290 | - BASHUNIT_HEADER_ASCII_ART 291 | - BASHUNIT_SIMPLE_OUTPUT 292 | - BASHUNIT_STOP_ON_FAILURE 293 | - BASHUNIT_SHOW_EXECUTION_TIME 294 | - BASHUNIT_DEFAULT_PATH 295 | - BASHUNIT_LOG_JUNIT 296 | - BASHUNIT_REPORT_HTML 297 | 298 | ## [0.14.0](https://github.com/TypedDevs/bashunit/compare/0.13.0...0.14.0) - 2024-07-14 299 | 300 | - Fix echo does not break test execution results 301 | - Add bashunit facade to enable custom assertions 302 | - Document how to verify the `sha256sum` of the final executable 303 | - Generate checksum on build 304 | - Enable display execution time on macOS with `SHOW_EXECUTION_TIME` 305 | - Support for displaying the clock without `perl` (for non-macOS) 306 | - Enable strict mode 307 | - Add `--log-junit ` option 308 | - Add `-r|--report-html ` option 309 | - Add `--debug` option 310 | - Add `dump` and `dd` functions for local debugging 311 | 312 | ## [0.13.0](https://github.com/TypedDevs/bashunit/compare/0.12.0...0.13.0) - 2024-06-23 313 | 314 | - Allow calling assertions standalone outside tests 315 | - Add the latest version when installing beta 316 | - Add `assert_line_count` 317 | - Add hash to the installation script when installing a beta version 318 | - Add GitHub Actions to installation doc 319 | 320 | ## [0.12.0](https://github.com/TypedDevs/bashunit/compare/0.11.0...0.12.0) - 2024-06-11 321 | 322 | - Add missing assertion in non-stable versions 323 | - Fix test with `rm` command in macOS 324 | - Add multi-invokers; consolidate parameterized-testing documentation 325 | - Add `fail()` function 326 | - Remove all test mocks after each test case 327 | 328 | ## [0.11.0](https://github.com/TypedDevs/bashunit/compare/0.10.1...0.11.0) - 2024-03-02 329 | 330 | - Add `--upgrade` option to `./bashunit` 331 | - Remove support to deprecated `setUp`, `tearDown`, `setUpBeforeScript` and `tearDownAfterScript` functions 332 | - Optimize test execution time 333 | - Test functions are now run in the order they're defined in a test file 334 | - Increase contrast of test results 335 | 336 | ## [0.10.1](https://github.com/TypedDevs/bashunit/compare/0.10.0...0.10.1) - 2023-11-13 337 | 338 | - Fix find tests inside folder 339 | - Add current date on beta installation version 340 | 341 | ## [0.10.0](https://github.com/TypedDevs/bashunit/compare/0.9.0...0.10.0) - 2023-11-09 342 | 343 | - Installer no longer needs git 344 | - Add `assert_contains_ignore_case` 345 | - Add `assert_equals_ignore_colors` 346 | - Add `assert_match_snapshot` 347 | - Add `SHOW_EXECUTION_TIME` to environment config 348 | - Add docs for environment variables 349 | - Improve data provider output 350 | - Add .env variable `DEFAULT_PATH` 351 | - Improve duplicated function names output 352 | - Allow installing (non-stable) beta using the installer 353 | 354 | ## [0.9.0](https://github.com/TypedDevs/bashunit/compare/0.8.0...0.9.0) - 2023-10-15 355 | 356 | - Optimised docs Fonts (Serving directly from origin instead of Google Fonts _proxy_) 357 | - Add Brew installation to docs 358 | - Add `--help` option 359 | - Add `-e|--env` option 360 | - Add `-S|--stop-on-failure` option 361 | - Add data_provider 362 | - Add blog posts to the website 363 | - Add `assert_string_not_starts_with` 364 | - Add `assert_string_starts_with` 365 | - Add `assert_string_ends_with` 366 | - Add `assert_string_not_ends_with` 367 | - Add `assert_less_than` 368 | - Add `assert_less_or_equal_than` 369 | - Add `assert_greater_than` 370 | - Add `assert_greater_or_equal_than` 371 | 372 | ## [0.8.0](https://github.com/TypedDevs/bashunit/compare/0.7.0...0.8.0) - 2023-10-08 373 | 374 | - Rename these functions from camelCase to snake_case: 375 | - `setUp` -> `set_up` 376 | - `tearDown` -> `tear_down` 377 | - `setUpBeforeScript` -> `set_up_before_script` 378 | - `tearDownAfterScript` -> `tear_down_after_script` 379 | - Add --version option 380 | - Add -v|--verbose option 381 | - Add ASCII art logo 382 | - Find all test on a directory 383 | - Add skip and todo functions 384 | - Add SIMPLE_OUTPUT to `.env` 385 | - Allow using `main` or `latest` when using install.sh 386 | 387 | ## [0.7.0](https://github.com/TypedDevs/bashunit/compare/0.6.0...0.7.0) - 2023-10-02 388 | 389 | - Added `--simple` argument for a simpler output 390 | - Manage error when test execution fails 391 | - Split install and build scripts 392 | - Added these functions 393 | - `mock` 394 | - `spy` 395 | - `assert_have_been_called` 396 | - `assert_have_been_called_with` 397 | - `assert_have_been_called_times` 398 | - `assert_file_exists` 399 | - `assert_file_not_exists` 400 | - `assert_is_file_empty` 401 | - `assert_is_file` 402 | - `assert_directory_exists` 403 | - `assert_directory_not_exists` 404 | - `assert_is_directory` 405 | - `assert_is_directory_empty` 406 | - `assert_is_directory_not_empty` 407 | - `assert_is_directory_readable` 408 | - `assert_is_directory_not_readable` 409 | - `assert_is_directory_writable` 410 | - `assert_is_directory_not_writable` 411 | - Rename assertions from camelCase to snake_case: 412 | - `assertEquals` -> `assert_equals` 413 | - `assertNotEquals` -> `assert_not_equals` 414 | - `assertEmpty` -> `assert_empty` 415 | - `assertNotEmpty` -> `assert_not_empty` 416 | - `assertContains` -> `assert_contains` 417 | - `assertNotContains` -> `assert_not_contains` 418 | - `assertMatches` -> `assert_matches` 419 | - `assertNotMatches` -> `assert_not_matches` 420 | - `assertExitCode` -> `assert_exit_code` 421 | - `assertSuccessfulCode` -> `assert_successful_code` 422 | - `assertGeneralError` -> `assert_general_error` 423 | - `assertCommandNotFound` -> `assert_command_not_found` 424 | - `assertArrayContains` -> `assert_array_contains` 425 | - `assertArrayNotContains` -> `assert_array_not_contains` 426 | 427 | ## [0.6.0](https://github.com/TypedDevs/bashunit/compare/0.5.0...0.6.0) - 2023-09-19 428 | 429 | - Added `assertExitCode` 430 | - Added `assertSuccessfulCode` 431 | - Added `assertGeneralError` 432 | - Added `assertCommandNotFound` 433 | - Added `assertArrayContains` 434 | - Added `assertArrayNotContains` 435 | - Added `assertEmpty` 436 | - Added `assertNotEmpty` 437 | - Added `setUp`, `setUpBeforeScript`, `tearDown` and `tearDownAfterScript` function execution before and/or after test and/or script execution 438 | - Improved the readability of the assert by using guard clause 439 | - Update documentation 440 | - Add support for the static analysis on macOS 441 | - Fix bug with watcher for the development of bashunit 442 | - Fix error on count assertions 443 | - Added pipeline to add contributors to the readme 444 | - Added documentation with VitePress 445 | - Stop runner when found duplicate test functions 446 | 447 | ## [0.5.0](https://github.com/TypedDevs/bashunit/compare/0.4.0...0.5.0) - 2023-09-10 448 | 449 | - Added logo 450 | - Added `assertNotEquals` 451 | - Added `assertMatches` 452 | - Added `assertNotMatches` 453 | - Added `make test/watch` to run your test every second 454 | - Added time taken to run the test in ms (only to Linux) 455 | - Simplified assertions over test results 456 | - Added acceptance test to the library 457 | - Added pre-commit to the project 458 | - Allow parallel tests to run base on a .env configuration enabled by default 459 | - Added static analysis tools to the deployment pipelines 460 | - New summary output 461 | 462 | ## [0.4.0](https://github.com/TypedDevs/bashunit/compare/0.3.0...0.4.0) - 2023-09-08 463 | 464 | - Better output colors and symbols 465 | - Add option `--filter` to `./bashunit` script 466 | - Trigger tests filtered by name 467 | - Change the output styles 468 | - Emojis 469 | - Colors 470 | - Bolds 471 | - Added count to all test 472 | 473 | ## [0.3.0](https://github.com/TypedDevs/bashunit/compare/0.2.0...0.3.0) - 2023-09-07 474 | 475 | - Added `assertContains` 476 | - Added `assertNotContains` 477 | - Display Passed tests in green, and Failed tests in red 478 | - Avoid stop running tests after a failing one test 479 | 480 | ## [0.2.0](https://github.com/TypedDevs/bashunit/compare/0.1.0...0.2.0) - 2023-09-05 481 | 482 | - Fix keeping in memory test func after running them 483 | - Create a `./bashunit` entry point 484 | - Change ROOT_DIR to BASHUNIT_ROOT_DIR 485 | - Allow writing test with camelCase as well 486 | - Allow running example log_test from anywhere 487 | 488 | ## [0.1.0](https://github.com/TypedDevs/bashunit/compare/27269c2...0.1.0) - 2023-09-04 489 | 490 | - Added `assertEquals` function 491 | --------------------------------------------------------------------------------