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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Changed
- A `--tag` that matches nothing names the tags the run saw, or says no test carries one. Tags appear only in `# @tag` comments and nothing lists them, so a typo had no way back (#1265)

## [0.48.0](https://github.com/TypedDevs/bashunit/compare/0.47.0...0.48.0) - 2026-08-14

### Added
Expand Down
14 changes: 14 additions & 0 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,20 @@ bashunit test tests/ --exclude-tag integration
```
:::

A tag exists only where someone wrote it, and nothing lists the ones in use, so
a `--tag` that selects nothing names the tags the run actually saw:

```
No tests found
No test matches tag 'integraton'. Tags in the selected files: integration,slow.
```

When no test carries a tag at all, it says that instead — a different mistake:

```
No test matches tag 'anything'. No test in the selected files carries a '# @tag'.
```

#### File-level tags

`# @tags <list>` applies every name in the list to **all** tests in that file,
Expand Down
24 changes: 24 additions & 0 deletions src/console/summary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

# Run totals, execution time and hook completion.

##
# Explains an empty selection that `--tag` caused, by naming the tags the run
# actually saw.
#
# A tag is a user-defined string and nothing lists them, so a typo leaves the
# reader with no way to find the right one -- worse off than a bad `--filter`,
# where the test names are at least visible in a normal run (#1265).
##
function bashunit::console_results::print_tag_hint() {
local tag_filter="${_BASHUNIT_ACTIVE_TAG_FILTER:-}"
[ -n "$tag_filter" ] || return 0

if [ -n "${_BASHUNIT_SEEN_TAGS:-}" ]; then
printf "%sNo test matches tag '%s'. Tags in the selected files: %s.%s\n" \
"${_BASHUNIT_COLOR_FAINT:-}" "$tag_filter" "$_BASHUNIT_SEEN_TAGS" \
"${_BASHUNIT_COLOR_DEFAULT:-}"
return 0
fi

printf "%sNo test matches tag '%s'. No test in the selected files carries a '# @tag'.%s\n" \
"${_BASHUNIT_COLOR_FAINT:-}" "$tag_filter" "${_BASHUNIT_COLOR_DEFAULT:-}"
}

##
# Explains an empty selection that `--filter` caused.
#
Expand Down Expand Up @@ -211,6 +234,7 @@ function bashunit::console_results::render_result() {
if [ "$total_tests" -eq 0 ]; then
printf "\n%s%s%s\n" "$_BASHUNIT_COLOR_RETURN_ERROR" " No tests found " "$_BASHUNIT_COLOR_DEFAULT"
bashunit::console_results::print_filter_hint
bashunit::console_results::print_tag_hint
bashunit::console_results::print_execution_time
return 1
fi
Expand Down
19 changes: 19 additions & 0 deletions src/helper/tags.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

# @tag extraction and matching.

# Every tag seen this run, comma separated. Accumulated as each file is scanned
# so an empty --tag selection can name the tags that exist (#1265).
_BASHUNIT_SEEN_TAGS=""

#
# Scans a script once and caches its test-function -> tags pairs.
# Memoized by resolved path, so repeated calls for the same file do not rescan.
Expand Down Expand Up @@ -44,6 +48,21 @@ function bashunit::helper::build_tags_map() {
[ -z "$fn" ] && continue
_BASHUNIT_TAGS_MAP_FNS[count]="$fn"
_BASHUNIT_TAGS_MAP_TAGS[count]="$tags"
# Remember every tag the run has seen, so a --tag that selects nothing can
# name the ones that exist. The map itself is cached per script, so only
# the last file's would survive to the summary. Tags are user-defined
# strings with no other way to list them, which is what makes a typo a
# dead end (#1265). Per file, not per test: build_tags_map is cached.
local _seen_tag
local _old_ifs=$IFS
IFS=','
for _seen_tag in $tags; do
case ",$_BASHUNIT_SEEN_TAGS," in
*",$_seen_tag,"*) ;;
*) _BASHUNIT_SEEN_TAGS="${_BASHUNIT_SEEN_TAGS:+$_BASHUNIT_SEEN_TAGS,}$_seen_tag" ;;
esac
done
IFS=$_old_ifs
count=$((count + 1))
done < <(awk '
# An uninitialised awk variable used as a subscript is the empty string,
Expand Down
1 change: 1 addition & 0 deletions src/runner/discovery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function bashunit::runner::load_test_files() {
# Carried to the summary so an empty selection can explain itself: the filter
# is a local here, and the report renders long after this returns.
_BASHUNIT_ACTIVE_FILTER="$filter"
_BASHUNIT_ACTIVE_TAG_FILTER="$tag_filter"
shift 3
local IFS=$' \t\n'
local -a files
Expand Down
57 changes: 57 additions & 0 deletions tests/acceptance/bashunit_filter_hint_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,60 @@ function test_the_hint_reaches_a_parallel_run_too() {
assert_contains "No tests found" "$output"
assert_contains "test_beta_case" "$output"
}

# `--filter` explains itself when it selects nothing (#1237); `--tag` did not,
# and tags are worse off: they are user-defined strings with no way to list
# them, so a typo leaves you guessing what the file actually declares.
function _run_tagged() { # $@ = flags
{
printf '%s\n' '#!/usr/bin/env bash'
printf '%s\n' '# @tag integration'
printf '%s\n' 'function test_alpha() { assert_same 1 1; }'
printf '%s\n' '# @tag slow'
printf '%s\n' 'function test_beta() { assert_same 1 1; }'
} >"$WORKDIR/tag_test.sh"

(cd "$WORKDIR" && "$BASHUNIT_BIN" --no-parallel "$@" tag_test.sh 2>&1 | strip_ansi) || true
}

function test_a_tag_matching_nothing_names_the_tags_that_exist() {
local output
output="$(_run_tagged --tag integraton)"

assert_contains "No tests found" "$output"
assert_contains "integration" "$output"
assert_contains "slow" "$output"
}

# The hint must not fire when the tag did select something.
function test_a_matching_tag_prints_no_hint() {
local output
output="$(_run_tagged --tag integration)"

assert_contains "1 passed" "$output"
assert_not_contains "Tags in the selected files" "$output"
}

# And an empty run with no tag filter stays exactly as it was.
function test_an_empty_run_without_a_tag_is_unchanged() {
local output
output="$(_run_tagged --filter nothing_like_this)"

assert_not_contains "Tags in the selected files" "$output"
}

# A file with no tags at all is a different mistake from a mistyped tag, and
# naming an empty list ("Tags in the selected files: .") would say nothing.
function test_a_tag_against_an_untagged_file_says_there_are_none() {
printf '%s\n' '#!/usr/bin/env bash' \
'function test_untagged() { assert_same 1 1; }' >"$WORKDIR/untagged_test.sh"

# `|| true`: an empty run exits 1, which under --strict (set -e, pipefail)
# would abort the assignment before the assertions ran.
local output
output="$(cd "$WORKDIR" && "$BASHUNIT_BIN" --no-parallel --tag anything untagged_test.sh 2>&1 |
strip_ansi || true)"

assert_contains "No test in the selected files carries a '# @tag'" "$output"
assert_not_contains "Tags in the selected files" "$output"
}
Loading