diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2211cd..8d89f7a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added - `--list` (alias `--dry-run`) prints the tests a run would execute, without running them; `--list-format json` emits file, function, name, line and tags. Honours every selection flag, including `--shard` and `--random-order --seed` ordering (#1007) +- `# @tags a b` above any top-level line applies those tags to every test in the file, unioned with per-function `# @tag` (#1008) +- `--tag` accepts expressions: `'a&&b'` (AND) and `'!a'` (NOT), combinable as `'a&&!b'`. Repeated `--tag` flags keep OR semantics, and `--exclude-tag` still wins (#1008) - The coverage engine in use is reported by `--verbose`, and an explicit `BASHUNIT_COVERAGE_ENGINE=xtrace` that the running Bash cannot honour now warns instead of being silently ignored (#1005) ### Changed diff --git a/completions/_bashunit b/completions/_bashunit index 6b989877..8202c39f 100644 --- a/completions/_bashunit +++ b/completions/_bashunit @@ -65,7 +65,7 @@ _bashunit() { '(-a --assert)'{-a,--assert}'[Run a standalone assert function]:function:' \ '(-e --env --boot)'{-e,--env,--boot}'[Load a custom env/bootstrap file]:file:_files' \ '(-f --filter)'{-f,--filter}'[Only run tests matching the name]:name:' \ - '--tag[Only run tests with matching @tag]:tag:' \ + '--tag[Only run tests with matching @tag; supports a&&b and !a]:tag:' \ '--exclude-tag[Skip tests with matching @tag]:tag:' \ '--log-junit[Write JUnit XML report]:file:_files' \ '--report-junit[Write JUnit XML report]:file:_files' \ diff --git a/docs/command-line.md b/docs/command-line.md index d6ab0f1e..854cb87a 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -61,7 +61,7 @@ bashunit test tests/ --parallel --simple | `-a, --assert ` | Run a standalone assert function | | `-e, --env, --boot ` | Load custom env/bootstrap file (supports args) | | `-f, --filter ` | Only run tests matching name | -| `--tag ` | Only run tests with matching `@tag` (repeatable) | +| `--tag ` | Only run tests with matching `@tag`; supports `a&&b` and `!a` | | `--exclude-tag ` | Skip tests with matching `@tag` (repeatable) | | `--output ` | Output format (`tap` for TAP version 13) | | `-w, --watch` | Watch files and re-run tests on change | @@ -150,12 +150,14 @@ logic across names; `--exclude-tag` wins when a test matches both. ::: code-group ```bash [Annotate tests] +# @tags integration # applies to every test in this file + # @tag slow function test_heavy_computation() { ... } -# @tag integration +# @tag api function test_api_call() { ... } @@ -167,6 +169,47 @@ bashunit test tests/ --exclude-tag integration ``` ::: +#### File-level tags + +`# @tags ` applies every name in the list to **all** tests in that file, +so tagging a whole suite no longer means repeating `# @tag` above each function. +It may appear anywhere at top level, and unions with per-function `# @tag` +(a name carried at both levels is not duplicated). + +Note the plural: `# @tags a b` is a space-separated list applying to the file, +while `# @tag a b` is a single tag literally named `a b` applying to the next +function. + +#### Tag expressions + +A single `--tag` value can combine terms with `&&` (AND) and `!` (NOT): + +```bash +bashunit test tests/ --tag 'slow&&db' # both tags +bashunit test tests/ --tag '!slow' # everything except slow +bashunit test tests/ --tag 'db&&!slow' # db, but not slow +``` + +Repeating the flag still means OR *between* expressions, so existing usage is +unchanged: + +```bash +bashunit test tests/ --tag 'db&&slow' --tag api # (db AND slow) OR api +``` + +`!` matches untagged tests too — `--tag '!slow'` selects a test with no tags at +all. `--exclude-tag` continues to win over any expression match. + +A malformed expression (`'a&&'`, `'&&'`, a bare `'!'`) is rejected with an error +and a non-zero exit, rather than silently selecting nothing — or, in the case of +a trailing `&&`, silently behaving like the term before it. + +Use [`--list`](#list) to check what an expression actually selects: + +```bash +bashunit --list --tag 'db&&!slow' tests/ +``` + ### Output format > `bashunit test --output ` diff --git a/src/console/header.sh b/src/console/header.sh index ac28bf2a..5ab98a45 100644 --- a/src/console/header.sh +++ b/src/console/header.sh @@ -120,7 +120,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) + --tag Only run tests with matching @tag (repeatable, OR logic). + Supports 'a&&b' (AND) and '!a' (NOT) --exclude-tag Skip tests with matching @tag (repeatable, exclude wins) --log-junit, --report-junit Write JUnit XML report -j, --jobs Run tests in parallel with max N concurrent jobs ("auto" = CPU cores) diff --git a/src/helper/tags.sh b/src/helper/tags.sh index ff0979e9..0cd20cbd 100644 --- a/src/helper/tags.sh +++ b/src/helper/tags.sh @@ -46,6 +46,21 @@ function bashunit::helper::build_tags_map() { _BASHUNIT_TAGS_MAP_TAGS[count]="$tags" count=$((count + 1)) done < <(awk ' + # An uninitialised awk variable used as a subscript is the empty string, + # not 0, so the first function would land in order[""] and be unreachable + # from the numeric loop in END. + BEGIN { n = 0 } + # File-level tags: `# @tags a b` applies to every test in the file. Checked + # before the singular rule and before the generic comment rule, and space + # separated because it is a list rather than one tag per line. + /^[[:space:]]*#[[:space:]]*@tags[[:space:]]/ { + t = $0 + sub(/^[[:space:]]*#[[:space:]]*@tags[[:space:]]+/, "", t) + sub(/[[:space:]]+$/, "", t) + gsub(/[[:space:]]+/, ",", t) + if (t != "") { filetags = (filetags == "" ? t : filetags "," t) } + next + } /^[[:space:]]*#[[:space:]]*@tag[[:space:]]/ { t = $0 sub(/^[[:space:]]*#[[:space:]]*@tag[[:space:]]+/, "", t) @@ -57,11 +72,35 @@ function bashunit::helper::build_tags_map() { fn = $0 sub(/^[[:space:]]*(function[[:space:]]+)?/, "", fn) sub(/[[:space:]]*\(\).*/, "", fn) - if (tags != "") printf "%s\t%s\n", fn, tags + # Buffered rather than printed here so a `# @tags` line placed below the + # functions still applies to them (single pass, order preserved). + order[n] = fn + own[n] = tags + n++ tags = "" next } { tags = "" } + END { + for (i = 0; i < n; i++) { + combined = own[i] + if (filetags != "") { + combined = (combined == "" ? filetags : combined "," filetags) + } + if (combined == "") { continue } + # Function tags come first (nearest-first, as before); a tag carried at + # both levels is emitted once. + count = split(combined, parts, ",") + out = "" + delete seen + for (j = 1; j <= count; j++) { + if (parts[j] == "" || (parts[j] in seen)) { continue } + seen[parts[j]] = 1 + out = (out == "" ? parts[j] : out "," parts[j]) + } + if (out != "") { printf "%s\t%s\n", order[i], out } + } + } ' "$script" 2>/dev/null) } @@ -86,13 +125,91 @@ function bashunit::helper::tags_for_function() { } +# +# Whether a comma-separated tag list contains an exact tag. +# A tag may itself contain spaces (`# @tag needs a db`), so the split is on +# commas only. +# Arguments: $1 - comma-separated tags, $2 - tag to find +# +function bashunit::helper::_tags_contain() { + local fn_tags="$1" + local needle="$2" + local IFS=',' + local tag + for tag in $fn_tags; do + if [ "$tag" = "$needle" ]; then + return 0 + fi + done + return 1 +} + +# +# Evaluates one tag expression against a function's tags. +# An expression is `term` or `term&&term&&...`, where a term is a tag name +# optionally prefixed with `!` to negate it. Surrounding whitespace is ignored. +# A malformed term (empty, or a bare `!`) matches nothing; the CLI rejects those +# up front so they cannot silently widen a selection. +# Arguments: $1 - comma-separated tags for the function, $2 - the expression +# Returns: 0 when the expression holds, 1 otherwise +# +function bashunit::helper::tag_expression_matches() { + local fn_tags="$1" + local rest="$2" + + # Always consume one term per iteration and stop only after the last one, so + # a trailing separator (`a&&`) yields a final empty term and is rejected. A + # `while [ -n "$rest" ]` loop would silently treat `a&&` as `a`, and an empty + # expression as "matches everything". + local term negate more=true + while [ "$more" = true ]; do + case "$rest" in + *"&&"*) + term="${rest%%&&*}" + rest="${rest#*&&}" + ;; + *) + term="$rest" + rest="" + more=false + ;; + esac + + term="${term#"${term%%[![:space:]]*}"}" + term="${term%"${term##*[![:space:]]}"}" + + negate=false + case "$term" in + '!'*) + negate=true + term="${term#!}" + term="${term#"${term%%[![:space:]]*}"}" + ;; + esac + + if [ -z "$term" ]; then + return 1 + fi + + if bashunit::helper::_tags_contain "$fn_tags" "$term"; then + if [ "$negate" = true ]; then + return 1 + fi + elif [ "$negate" = false ]; then + return 1 + fi + done + + return 0 +} + # # 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. +# Include is a comma-separated list of expressions, OR'd together: repeated +# --tag flags arrive comma-joined, so plain tags keep their previous meaning. +# Exclude uses OR logic (any match fails) and takes precedence over include. # Arguments: $1 - comma-separated tags for the function, -# $2 - comma-separated include tags (empty = no filter), +# $2 - comma-separated include expressions (empty = no filter), # $3 - comma-separated exclude tags (empty = no filter) # Returns: 0 if the function should run, 1 if it should be skipped # @@ -115,20 +232,15 @@ function bashunit::helper::function_matches_tags() { done fi - # Check include tags (OR logic: any match passes) + # Check include expressions (OR logic: any match passes). An untagged + # function is not short-circuited here any more: `!slow` must match it. 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 + local expression + for expression in $include_tags; do + if bashunit::helper::tag_expression_matches "$fn_tags" "$expression"; then + return 0 + fi done return 1 fi diff --git a/src/main/test.sh b/src/main/test.sh index 8621bead..daa5ddc7 100644 --- a/src/main/test.sh +++ b/src/main/test.sh @@ -46,6 +46,7 @@ function bashunit::main::cmd_test() { shift ;; --tag) + bashunit::main::require_valid_tag_expression_or_exit "$2" if [ -z "$tag_filter" ]; then tag_filter="$2" else diff --git a/src/main/validate.sh b/src/main/validate.sh index df45f158..0ca9326e 100644 --- a/src/main/validate.sh +++ b/src/main/validate.sh @@ -138,6 +138,55 @@ function bashunit::main::validate_config_or_exit() { esac } +## +# Validates a `--tag` value and exits non-zero on a malformed expression. +# +# A value is a comma-separated list of expressions, each `term&&term&&...` with +# an optional leading `!` per term. An empty term (`a&&`, `&&`, a bare `!`) can +# never match, so without this check the flag would silently select nothing — +# the failure shape #871/#873 closed for other settings. +# Arguments: $1 - the raw --tag value +## +function bashunit::main::require_valid_tag_expression_or_exit() { + local value="${1:-}" + local IFS=',' + local expression + for expression in $value; do + local rest="$expression" + local term more=true + # Mirrors bashunit::helper::tag_expression_matches: one term per iteration, + # stopping after the last, so `a&&` produces an empty final term instead of + # ending the loop early and looking valid. + while [ "$more" = true ]; do + case "$rest" in + *"&&"*) + term="${rest%%&&*}" + rest="${rest#*&&}" + ;; + *) + term="$rest" + rest="" + more=false + ;; + esac + term="${term#"${term%%[![:space:]]*}"}" + term="${term%"${term##*[![:space:]]}"}" + case "$term" in + '!'*) + term="${term#!}" + term="${term#"${term%%[![:space:]]*}"}" + ;; + esac + if [ -z "$term" ]; then + printf "%sError: invalid tag expression '%s' for --tag. \ +Use 'a', 'a&&b', '!a' or 'a&&!b'.%s\n" \ + "${_BASHUNIT_COLOR_FAILED}" "$expression" "${_BASHUNIT_COLOR_DEFAULT}" >&2 + exit 1 + fi + done + done +} + ## # Validates a `--shard /` spec and exports the parts, or prints an # error and exits non-zero. Requires numeric index/total with 1 <= index <= total. diff --git a/tests/acceptance/bashunit_tag_test.sh b/tests/acceptance/bashunit_tag_test.sh index 00842bf4..b866288e 100644 --- a/tests/acceptance/bashunit_tag_test.sh +++ b/tests/acceptance/bashunit_tag_test.sh @@ -83,3 +83,97 @@ function test_tag_nonexistent_runs_zero_tests() { assert_contains "0 total" "$output" } + +# Tag expressions and file-level tags (#1008). Selection is asserted through +# --list rather than by parsing run output: it is the same filtering pipeline +# and the comparison is exact. +TAGS_FIXTURE=./tests/acceptance/fixtures/test_bashunit_with_tags.sh +FILE_TAGS_FIXTURE=./tests/acceptance/fixtures/file_tags_fixture.sh + +function test_and_expression_selects_only_tests_with_both_tags() { + local output + output=$(./bashunit --list --tag 'slow&&database' "$TAGS_FIXTURE" 2>/dev/null) + + assert_same "$TAGS_FIXTURE::test_slow_database_query" "$output" +} + +function test_negation_expression_excludes_the_tag() { + local output + output=$(./bashunit --list --tag '!slow' "$TAGS_FIXTURE" 2>/dev/null) + + assert_same "\ +$TAGS_FIXTURE::test_fast_operation +$TAGS_FIXTURE::test_no_tags" "$output" +} + +function test_and_with_negation_expression() { + local output + output=$(./bashunit --list --tag 'slow&&!database' "$TAGS_FIXTURE" 2>/dev/null) + + assert_same "$TAGS_FIXTURE::test_slow_operation" "$output" +} + +# Back-compat: repeated --tag flags stay OR. +function test_repeated_tag_flags_still_use_or_logic() { + local output + output=$(./bashunit --list --tag fast --tag database "$TAGS_FIXTURE" 2>/dev/null) + + assert_same "\ +$TAGS_FIXTURE::test_fast_operation +$TAGS_FIXTURE::test_slow_database_query" "$output" +} + +function test_exclude_tag_still_wins_over_an_expression() { + local output + output=$(./bashunit --list --tag 'slow&&database' --exclude-tag database "$TAGS_FIXTURE" 2>/dev/null) + + assert_empty "$output" +} + +function test_file_level_tags_select_every_test_in_the_file() { + local output + output=$(./bashunit --list --tag integration "$FILE_TAGS_FIXTURE" 2>/dev/null) + + assert_same "\ +$FILE_TAGS_FIXTURE::test_inherits_file_tags +$FILE_TAGS_FIXTURE::test_inherits_and_adds" "$output" +} + +function test_file_level_and_function_tags_combine_in_an_expression() { + local output + output=$(./bashunit --list --tag 'db&&slow' "$FILE_TAGS_FIXTURE" 2>/dev/null) + + assert_same "$FILE_TAGS_FIXTURE::test_inherits_and_adds" "$output" +} + +function test_a_malformed_tag_expression_exits_non_zero() { + local exit_code=0 + ./bashunit --list --tag 'slow&&' "$TAGS_FIXTURE" >/dev/null 2>&1 || exit_code=$? + + assert_equals 1 "$exit_code" +} + +function test_a_malformed_tag_expression_explains_itself() { + local output + output=$(./bashunit --list --tag '!' "$TAGS_FIXTURE" 2>&1) || true + + assert_contains "invalid tag expression" "$output" +} + +# The whole point of rejecting it: a malformed expression must not quietly +# behave like "no filter" and run the entire suite. +function test_a_malformed_tag_expression_does_not_select_everything() { + local output + output=$(./bashunit --list --tag '&&' "$TAGS_FIXTURE" 2>/dev/null) || true + + assert_empty "$output" +} + +# A trailing separator is the case that reads as valid: `slow&&` used to be +# accepted and silently evaluated as plain `slow`. +function test_a_trailing_separator_is_rejected_rather_than_narrowed() { + local output + output=$(./bashunit --list --tag 'slow&&' "$TAGS_FIXTURE" 2>/dev/null) || true + + assert_empty "$output" +} diff --git a/tests/acceptance/fixtures/file_tags_fixture.sh b/tests/acceptance/fixtures/file_tags_fixture.sh new file mode 100644 index 00000000..d2d315a7 --- /dev/null +++ b/tests/acceptance/fixtures/file_tags_fixture.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# @tags integration db + +function test_inherits_file_tags() { + assert_same 1 1 +} + +# @tag slow +function test_inherits_and_adds() { + assert_same 2 2 +} diff --git a/tests/unit/fixtures/tags_map/sample_file_tags.sh b/tests/unit/fixtures/tags_map/sample_file_tags.sh new file mode 100644 index 00000000..88a14e79 --- /dev/null +++ b/tests/unit/fixtures/tags_map/sample_file_tags.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# @tags integration db + +# A plain comment between the file tags and the first test. + +function test_only_file_tags() { + : +} + +# @tag slow +function test_file_and_function_tags() { + : +} + +# @tag db +function test_duplicate_between_file_and_function() { + : +} diff --git a/tests/unit/fixtures/tags_map/sample_file_tags_late.sh b/tests/unit/fixtures/tags_map/sample_file_tags_late.sh new file mode 100644 index 00000000..97eadb55 --- /dev/null +++ b/tests/unit/fixtures/tags_map/sample_file_tags_late.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +function test_declared_before_the_file_tags() { + : +} + +# @tags smoke diff --git a/tests/unit/helper/tag_expression_test.sh b/tests/unit/helper/tag_expression_test.sh new file mode 100644 index 00000000..c9fb78b7 --- /dev/null +++ b/tests/unit/helper/tag_expression_test.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Tag expressions: AND (&&) and negation (!) inside a single --tag value. +# Repeated --tag flags arrive here comma-joined and keep OR semantics between +# them, so existing usage is unchanged (#1008). + +function matches() { # $1 fn tags, $2 include, $3 exclude + local exit_code=0 + bashunit::helper::function_matches_tags "$1" "${2:-}" "${3:-}" || exit_code=$? + echo "$exit_code" +} + +function test_a_plain_tag_still_matches() { + assert_same "0" "$(matches "slow,db" "slow")" +} + +function test_a_plain_tag_still_rejects_a_non_match() { + assert_same "1" "$(matches "slow,db" "api")" +} + +function test_repeated_tags_keep_or_semantics() { + assert_same "0" "$(matches "db" "api,db")" +} + +function test_and_requires_every_term() { + assert_same "0" "$(matches "slow,db" "slow&&db")" +} + +function test_and_rejects_when_one_term_is_missing() { + assert_same "1" "$(matches "slow" "slow&&db")" +} + +function test_negation_selects_tests_without_the_tag() { + assert_same "0" "$(matches "db" '!slow')" +} + +function test_negation_rejects_tests_carrying_the_tag() { + assert_same "1" "$(matches "slow,db" '!slow')" +} + +function test_negation_matches_an_untagged_test() { + assert_same "0" "$(matches "" '!slow')" +} + +function test_and_combined_with_negation() { + assert_same "0" "$(matches "db,fast" 'db&&!slow')" +} + +function test_and_combined_with_negation_rejects() { + assert_same "1" "$(matches "db,slow" 'db&&!slow')" +} + +function test_or_between_expressions() { + assert_same "0" "$(matches "api" 'db&&slow,api')" +} + +# --exclude-tag is a separate flag and keeps winning over any include match. +function test_exclude_still_wins_over_an_expression_match() { + assert_same "1" "$(matches "slow,db" "slow&&db" "db")" +} + +function test_exclude_still_wins_over_a_negation_match() { + assert_same "1" "$(matches "db" '!slow' "db")" +} + +function test_whitespace_around_terms_is_tolerated() { + assert_same "0" "$(matches "slow,db" " slow && db ")" +} + +function test_an_untagged_test_is_rejected_by_a_positive_expression() { + assert_same "1" "$(matches "" "slow&&db")" +} diff --git a/tests/unit/helper/tags_map_test.sh b/tests/unit/helper/tags_map_test.sh index f6fa1bd6..4908bf3e 100644 --- a/tests/unit/helper/tags_map_test.sh +++ b/tests/unit/helper/tags_map_test.sh @@ -61,3 +61,35 @@ function test_tags_map_invalidates_cache_per_script_path() { assert_same "single" \ "$(tags_for "$FIXTURE_TAGS_MAP" "test_single_tag")" } + +FIXTURE_FILE_TAGS="$(dirname "${BASH_SOURCE[0]}")/../fixtures/tags_map/sample_file_tags.sh" +FIXTURE_FILE_TAGS_LATE="$(dirname "${BASH_SOURCE[0]}")/../fixtures/tags_map/sample_file_tags_late.sh" + +# `# @tags a b` applies to every test in the file, so tagging a whole suite +# no longer means repeating `# @tag` above every function (#1008). +function test_file_level_tags_apply_to_a_test_with_no_tags_of_its_own() { + assert_same "integration,db" \ + "$(tags_for "$FIXTURE_FILE_TAGS" "test_only_file_tags")" +} + +function test_file_level_tags_union_with_function_tags_function_first() { + assert_same "slow,integration,db" \ + "$(tags_for "$FIXTURE_FILE_TAGS" "test_file_and_function_tags")" +} + +function test_a_tag_present_at_both_levels_is_not_duplicated() { + assert_same "db,integration" \ + "$(tags_for "$FIXTURE_FILE_TAGS" "test_duplicate_between_file_and_function")" +} + +# "Anywhere at top level": a file tag below the functions still applies. +function test_file_level_tags_apply_regardless_of_position() { + assert_same "smoke" \ + "$(tags_for "$FIXTURE_FILE_TAGS_LATE" "test_declared_before_the_file_tags")" +} + +# The singular form must not be swallowed by the plural rule or vice versa. +function test_singular_tag_is_still_function_scoped() { + assert_same "" \ + "$(tags_for "$FIXTURE_TAGS_MAP" "test_no_tags")" +}