diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e0af4a..cb642af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Add `assert_string_matches_format` and `assert_string_not_matches_format` with format placeholders (`%d`, `%s`, `%f`, `%i`, `%x`, `%e`, `%%`) - Add JSON assertions: `assert_json_key_exists`, `assert_json_contains`, `assert_json_equals` (requires `jq`) - Add duration assertions: `assert_duration`, `assert_duration_less_than`, `assert_duration_greater_than` +- Add `--tag` and `--exclude-tag` CLI flags for filtering tests by `# @tag` annotations ### Changed - Split Windows CI test jobs into parallel chunks to avoid timeouts diff --git a/src/console_header.sh b/src/console_header.sh index 2258b427..f7dea3cf 100644 --- a/src/console_header.sh +++ b/src/console_header.sh @@ -105,6 +105,8 @@ Options: -a, --assert Run a standalone assert function (deprecated: use 'bashunit assert') -e, --env, --boot Load a custom env/bootstrap file (supports args) -f, --filter Only run tests matching the name + --tag Only run tests with matching @tag (repeatable, OR logic) + --exclude-tag Skip tests with matching @tag (repeatable, exclude wins) --log-junit Write JUnit XML report -p, --parallel Run tests in parallel --no-parallel Run tests sequentially diff --git a/src/helpers.sh b/src/helpers.sh index a4cc18bb..5041581e 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -505,3 +505,117 @@ function bashunit::helper::find_function_at_line() { echo "$best_match" } + +# +# Extracts @tag annotations for a specific function from a test file. +# Looks for comment lines `# @tag ` immediately above the function definition. +# +# @param $1 string Function name +# @param $2 string Script file path +# +# @return string Comma-separated list of tags, or empty if none +# +function bashunit::helper::get_tags_for_function() { + local function_name="$1" + local script="$2" + + if [[ ! -f "$script" && -n "${BASHUNIT_WORKING_DIR:-}" ]]; then + script="$BASHUNIT_WORKING_DIR/$script" + fi + + if [[ ! -f "$script" ]]; then + return + fi + + # Find the line number of the function definition + local fn_line_num + fn_line_num=$(grep -n -E "(function[[:space:]]+)?${function_name}[[:space:]]*\(\)" "$script" 2>/dev/null | head -1) + if [ -z "$fn_line_num" ]; then + return + fi + fn_line_num="${fn_line_num%%:*}" + + # Walk backwards from the line above the function, collecting @tag comments + local tags="" + local check_line=$((fn_line_num - 1)) + while [ "$check_line" -ge 1 ]; do + local content + content=$(sed -n "${check_line}p" "$script") + local _re='^[[:space:]]*#[[:space:]]*@tag[[:space:]]' + if [[ "$content" =~ $_re ]]; then + local tag_name + tag_name=$(echo "$content" | sed -nE 's/^[[:space:]]*#[[:space:]]*@tag[[:space:]]+//p') + if [ -n "$tag_name" ]; then + if [ -z "$tags" ]; then + tags="$tag_name" + else + tags="$tags,$tag_name" + fi + fi + elif [[ "$content" =~ ^[[:space:]]*# ]]; then + # Other comment line, keep walking + : + elif [[ "$content" =~ ^[[:space:]]*$ ]]; then + # Empty line, stop looking + break + else + # Non-comment, non-empty line, stop + break + fi + check_line=$((check_line - 1)) + done + + echo "$tags" +} + +# +# Checks if a function's tags match the include/exclude filters. +# Include uses OR logic (any match passes). +# Exclude uses OR logic (any match fails). +# Exclude takes precedence over include. +# +# @param $1 string Comma-separated tags for the function +# @param $2 string Comma-separated include tags (empty = no filter) +# @param $3 string Comma-separated exclude tags (empty = no filter) +# +# @return 0 if function should run, 1 if it should be skipped +# +function bashunit::helper::function_matches_tags() { + local fn_tags="$1" + local include_tags="$2" + local exclude_tags="$3" + + # Check exclude tags first (exclude wins over include) + if [ -n "$exclude_tags" ]; then + local IFS=',' + local etag + for etag in $exclude_tags; do + local check_tag + for check_tag in $fn_tags; do + if [ "$check_tag" = "$etag" ]; then + return 1 + fi + done + done + fi + + # Check include tags (OR logic: any match passes) + if [ -n "$include_tags" ]; then + if [ -z "$fn_tags" ]; then + return 1 + fi + local IFS=',' + local itag + for itag in $include_tags; do + local check_tag + for check_tag in $fn_tags; do + if [ "$check_tag" = "$itag" ]; then + return 0 + fi + done + done + return 1 + fi + + return 0 +} diff --git a/src/main.sh b/src/main.sh index b75034b8..c9d8c119 100644 --- a/src/main.sh +++ b/src/main.sh @@ -5,6 +5,8 @@ ############################# function bashunit::main::cmd_test() { local filter="" + local tag_filter="" + local exclude_tag_filter="" local IFS=$' \t\n' local -a raw_args=() local raw_args_count=0 @@ -24,6 +26,22 @@ function bashunit::main::cmd_test() { filter="$2" shift ;; + --tag) + if [ -z "$tag_filter" ]; then + tag_filter="$2" + else + tag_filter="$tag_filter,$2" + fi + shift + ;; + --exclude-tag) + if [ -z "$exclude_tag_filter" ]; then + exclude_tag_filter="$2" + else + exclude_tag_filter="$exclude_tag_filter,$2" + fi + shift + ;; -s | --simple) export BASHUNIT_SIMPLE_OUTPUT=true ;; @@ -250,9 +268,9 @@ function bashunit::main::cmd_test() { # Bash 3.0 compatible: only pass args if we have files # (local args without =() creates a scalar, not an empty array) if [[ "$args_count" -gt 0 ]]; then - bashunit::main::exec_tests "$filter" "${args[@]}" + bashunit::main::exec_tests "$filter" "$tag_filter" "$exclude_tag_filter" "${args[@]}" else - bashunit::main::exec_tests "$filter" + bashunit::main::exec_tests "$filter" "$tag_filter" "$exclude_tag_filter" fi fi } @@ -462,7 +480,9 @@ function bashunit::main::cmd_assert() { ############################# function bashunit::main::exec_tests() { local filter=$1 - shift + local tag_filter="${2:-}" + local exclude_tag_filter="${3:-}" + shift 3 # Bash 3.0 compatible: collect files into array local test_files @@ -513,7 +533,7 @@ function bashunit::main::exec_tests() { printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '#' fi - bashunit::runner::load_test_files "$filter" "${test_files[@]}" + bashunit::runner::load_test_files "$filter" "$tag_filter" "$exclude_tag_filter" "${test_files[@]}" if bashunit::parallel::is_enabled; then wait diff --git a/src/runner.sh b/src/runner.sh index 8f2234bd..ab77d66c 100755 --- a/src/runner.sh +++ b/src/runner.sh @@ -14,7 +14,9 @@ function bashunit::runner::restore_workdir() { function bashunit::runner::load_test_files() { local filter=$1 - shift + local tag_filter="${2:-}" + local exclude_tag_filter="${3:-}" + shift 3 local IFS=$' \t\n' local -a files files=("$@") @@ -49,6 +51,19 @@ function bashunit::runner::load_test_files() { filtered_functions=$(bashunit::helper::get_functions_to_run "test" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS") local functions_for_script functions_for_script=$(bashunit::runner::functions_for_script "$test_file" "$filtered_functions") + # Apply tag filtering to the early check as well + if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then + local _early_filtered="" + local _early_fn + for _early_fn in $functions_for_script; do + local _early_tags + _early_tags=$(bashunit::helper::get_tags_for_function "$_early_fn" "$test_file") + if bashunit::helper::function_matches_tags "$_early_tags" "$tag_filter" "$exclude_tag_filter"; then + _early_filtered="$_early_filtered $_early_fn" + fi + done + functions_for_script="${_early_filtered# }" + fi if [[ -z "$functions_for_script" ]]; then bashunit::runner::clean_set_up_and_tear_down_after_script bashunit::runner::restore_workdir @@ -83,9 +98,9 @@ function bashunit::runner::load_test_files() { continue fi if bashunit::parallel::is_enabled; then - bashunit::runner::call_test_functions "$test_file" "$filter" 2>/dev/null & + bashunit::runner::call_test_functions "$test_file" "$filter" "$tag_filter" "$exclude_tag_filter" 2>/dev/null & else - bashunit::runner::call_test_functions "$test_file" "$filter" + bashunit::runner::call_test_functions "$test_file" "$filter" "$tag_filter" "$exclude_tag_filter" fi bashunit::runner::run_tear_down_after_script "$test_file" bashunit::runner::clean_set_up_and_tear_down_after_script @@ -322,6 +337,8 @@ function bashunit::runner::parse_data_provider_args() { function bashunit::runner::call_test_functions() { local script="$1" local filter="$2" + local tag_filter="${3:-}" + local exclude_tag_filter="${4:-}" local IFS=$' \t\n' local prefix="test" # Use cached function names for better performance @@ -337,6 +354,23 @@ function bashunit::runner::call_test_functions() { functions_to_run_count=$((functions_to_run_count + 1)) done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions") + # Apply tag filtering if --tag or --exclude-tag was specified + if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then + local -a tag_filtered=() + local tag_filtered_count=0 + local _tf_fn + for _tf_fn in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do + local fn_tags + fn_tags=$(bashunit::helper::get_tags_for_function "$_tf_fn" "$script") + if bashunit::helper::function_matches_tags "$fn_tags" "$tag_filter" "$exclude_tag_filter"; then + tag_filtered[tag_filtered_count]="$_tf_fn" + tag_filtered_count=$((tag_filtered_count + 1)) + fi + done + functions_to_run=("${tag_filtered[@]+"${tag_filtered[@]}"}") + functions_to_run_count=$tag_filtered_count + fi + if [[ "$functions_to_run_count" -le 0 ]]; then return fi diff --git a/tests/acceptance/bashunit_tag_test.sh b/tests/acceptance/bashunit_tag_test.sh new file mode 100644 index 00000000..00842bf4 --- /dev/null +++ b/tests/acceptance/bashunit_tag_test.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +function set_up_before_script() { + TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" +} + +function test_tag_runs_only_matching_tests() { + local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh + local output + + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag slow "$test_file" 2>&1) + + assert_contains "2 passed" "$output" + assert_contains "2 total" "$output" +} + +function test_tag_fast_runs_only_fast_tests() { + local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh + local output + + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag fast "$test_file" 2>&1) + + assert_contains "1 passed" "$output" + assert_contains "1 total" "$output" +} + +function test_tag_database_runs_only_database_tests() { + local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh + local output + + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag database "$test_file" 2>&1) + + assert_contains "1 passed" "$output" + assert_contains "1 total" "$output" +} + +function test_exclude_tag_skips_matching_tests() { + local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh + local output + + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --exclude-tag slow "$test_file" 2>&1) + + assert_contains "2 passed" "$output" + assert_contains "2 total" "$output" +} + +function test_exclude_tag_takes_precedence_over_tag() { + local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh + local output + + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag slow --exclude-tag database "$test_file" 2>&1) + + assert_contains "1 passed" "$output" + assert_contains "1 total" "$output" +} + +function test_multiple_tags_use_or_logic() { + local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh + local output + + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag slow --tag fast "$test_file" 2>&1) + + assert_contains "3 passed" "$output" + assert_contains "3 total" "$output" +} + +function test_no_tag_flags_runs_all_tests() { + local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh + local output + + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$test_file" 2>&1) + + assert_contains "4 passed" "$output" + assert_contains "4 total" "$output" +} + +function test_tag_nonexistent_runs_zero_tests() { + local test_file=./tests/acceptance/fixtures/test_bashunit_with_tags.sh + local output + + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" --tag nonexistent "$test_file" 2>&1) || true + + assert_contains "0 total" "$output" +} diff --git a/tests/acceptance/fixtures/test_bashunit_with_tags.sh b/tests/acceptance/fixtures/test_bashunit_with_tags.sh new file mode 100644 index 00000000..18b8a23d --- /dev/null +++ b/tests/acceptance/fixtures/test_bashunit_with_tags.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# @tag slow +function test_slow_operation() { + assert_same 1 1 +} + +# @tag fast +function test_fast_operation() { + assert_same 2 2 +} + +# @tag slow +# @tag database +function test_slow_database_query() { + assert_same 3 3 +} + +function test_no_tags() { + assert_same 4 4 +} diff --git a/tests/unit/helpers_tag_test.sh b/tests/unit/helpers_tag_test.sh new file mode 100644 index 00000000..71ee884f --- /dev/null +++ b/tests/unit/helpers_tag_test.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +function test_get_tags_for_function_returns_tags() { + local script="tests/acceptance/fixtures/test_bashunit_with_tags.sh" + local result + result=$(bashunit::helper::get_tags_for_function "test_slow_operation" "$script") + + assert_same "slow" "$result" +} + +function test_get_tags_for_function_returns_multiple_tags() { + local script="tests/acceptance/fixtures/test_bashunit_with_tags.sh" + local result + result=$(bashunit::helper::get_tags_for_function "test_slow_database_query" "$script") + + assert_contains "slow" "$result" + assert_contains "database" "$result" +} + +function test_get_tags_for_function_returns_empty_when_no_tags() { + local script="tests/acceptance/fixtures/test_bashunit_with_tags.sh" + local result + result=$(bashunit::helper::get_tags_for_function "test_no_tags" "$script") + + assert_empty "$result" +} + +function test_get_tags_for_function_returns_empty_for_nonexistent_function() { + local script="tests/acceptance/fixtures/test_bashunit_with_tags.sh" + local result + result=$(bashunit::helper::get_tags_for_function "test_nonexistent" "$script") + + assert_empty "$result" +} + +function test_function_matches_tags_include_match() { + local tags="slow,database" + local include_tags="slow" + local exclude_tags="" + + local exit_code=0 + bashunit::helper::function_matches_tags "$tags" "$include_tags" "$exclude_tags" || exit_code=$? + assert_same 0 "$exit_code" +} + +function test_function_matches_tags_include_no_match() { + local tags="fast" + local include_tags="slow" + local exclude_tags="" + + local exit_code=0 + bashunit::helper::function_matches_tags "$tags" "$include_tags" "$exclude_tags" || exit_code=$? + assert_same 1 "$exit_code" +} + +function test_function_matches_tags_exclude_match() { + local tags="slow,database" + local include_tags="" + local exclude_tags="slow" + + local exit_code=0 + bashunit::helper::function_matches_tags "$tags" "$include_tags" "$exclude_tags" || exit_code=$? + assert_same 1 "$exit_code" +} + +function test_function_matches_tags_exclude_wins_over_include() { + local tags="slow,database" + local include_tags="database" + local exclude_tags="slow" + + local exit_code=0 + bashunit::helper::function_matches_tags "$tags" "$include_tags" "$exclude_tags" || exit_code=$? + assert_same 1 "$exit_code" +} + +function test_function_matches_tags_no_tags_with_include_filter() { + local tags="" + local include_tags="slow" + local exclude_tags="" + + local exit_code=0 + bashunit::helper::function_matches_tags "$tags" "$include_tags" "$exclude_tags" || exit_code=$? + assert_same 1 "$exit_code" +} + +function test_function_matches_tags_no_tags_with_exclude_filter() { + local tags="" + local include_tags="" + local exclude_tags="slow" + + local exit_code=0 + bashunit::helper::function_matches_tags "$tags" "$include_tags" "$exclude_tags" || exit_code=$? + assert_same 0 "$exit_code" +} + +function test_function_matches_tags_multiple_include_or_logic() { + local tags="fast" + local include_tags="slow,fast" + local exclude_tags="" + + local exit_code=0 + bashunit::helper::function_matches_tags "$tags" "$include_tags" "$exclude_tags" || exit_code=$? + assert_same 0 "$exit_code" +} + +function test_function_matches_tags_multiple_exclude_or_logic() { + local tags="fast" + local include_tags="" + local exclude_tags="slow,fast" + + local exit_code=0 + bashunit::helper::function_matches_tags "$tags" "$include_tags" "$exclude_tags" || exit_code=$? + assert_same 1 "$exit_code" +}