diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e320bb2..e91de2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/command-line.md b/docs/command-line.md index 7e7cee5c..725ea453 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -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 ` applies every name in the list to **all** tests in that file, diff --git a/src/console/summary.sh b/src/console/summary.sh index d009383f..430ccd0e 100644 --- a/src/console/summary.sh +++ b/src/console/summary.sh @@ -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. # @@ -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 diff --git a/src/helper/tags.sh b/src/helper/tags.sh index 0cd20cbd..4346fbe7 100644 --- a/src/helper/tags.sh +++ b/src/helper/tags.sh @@ -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. @@ -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, diff --git a/src/runner/discovery.sh b/src/runner/discovery.sh index 62747046..75a75503 100644 --- a/src/runner/discovery.sh +++ b/src/runner/discovery.sh @@ -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 diff --git a/tests/acceptance/bashunit_filter_hint_test.sh b/tests/acceptance/bashunit_filter_hint_test.sh index b6bb50a7..fcb5f9cf 100644 --- a/tests/acceptance/bashunit_filter_hint_test.sh +++ b/tests/acceptance/bashunit_filter_hint_test.sh @@ -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" +}