Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/_bashunit
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down
47 changes: 45 additions & 2 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ bashunit test tests/ --parallel --simple
| `-a, --assert <fn> <args>` | Run a standalone assert function |
| `-e, --env, --boot <file>` | Load custom env/bootstrap file (supports args) |
| `-f, --filter <name>` | Only run tests matching name |
| `--tag <name>` | Only run tests with matching `@tag` (repeatable) |
| `--tag <expr>` | Only run tests with matching `@tag`; supports `a&&b` and `!a` |
| `--exclude-tag <name>` | Skip tests with matching `@tag` (repeatable) |
| `--output <format>` | Output format (`tap` for TAP version 13) |
| `-w, --watch` | Watch files and re-run tests on change |
Expand Down Expand Up @@ -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() {
...
}
Expand All @@ -167,6 +169,47 @@ bashunit test tests/ --exclude-tag integration
```
:::

#### File-level tags

`# @tags <list>` 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 <format>`
Expand Down
3 changes: 2 additions & 1 deletion src/console/header.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ Options:
-a, --assert <fn> <args> Run a standalone assert function (deprecated: use 'bashunit assert')
-e, --env, --boot <file> Load a custom env/bootstrap file (supports args)
-f, --filter <name> Only run tests matching the name
--tag <name> Only run tests with matching @tag (repeatable, OR logic)
--tag <expr> Only run tests with matching @tag (repeatable, OR logic).
Supports 'a&&b' (AND) and '!a' (NOT)
--exclude-tag <name> Skip tests with matching @tag (repeatable, exclude wins)
--log-junit, --report-junit <file> Write JUnit XML report
-j, --jobs <N|auto> Run tests in parallel with max N concurrent jobs ("auto" = CPU cores)
Expand Down
146 changes: 129 additions & 17 deletions src/helper/tags.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}

Expand All @@ -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
#
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/main/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/main/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <index>/<total>` spec and exports the parts, or prints an
# error and exits non-zero. Requires numeric index/total with 1 <= index <= total.
Expand Down
Loading
Loading