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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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