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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- `--test-timeout` no longer intermittently reports a fast test as timed out. The watchdog's process-group kill could miss when `set -m` had not made the backgrounded subshell a group leader, leaving it to sleep the full timeout and fire against an already-finished test; it is now signalled by pid directly and skips marking a test that already completed

### Added
- Shell tab-completion scripts for bash and zsh under `completions/` — subcommands, all `test` flags (with value hints like `--jobs auto`, `--output tap`, file completion for `--env`), and assertion names after `bashunit assert`. An anti-drift CI test fails when a flag is added to the CLI but not to the scripts (#778)
- `--rerun-failed` (env: `BASHUNIT_RERUN_FAILED`) replays only the tests that failed on the previous run. Every run records its failing tests as `<test_file>:<function_name>` in `.bashunit/last-failed` under the working directory (a green run clears it); with the flag, discovery is restricted to those files and functions. Falls back to the full suite with a notice when the cache is empty, composes with `--filter`/`--tag` (intersection) and `--parallel`. Add `.bashunit/` to your `.gitignore` (#776)
- Coverage badge: an optional, nightly `coverage.yml` CI workflow dogfoods `--coverage` over the unit suite, uploads `coverage/lcov.info` as an artifact, and publishes a shields.io endpoint badge (now shown in the README) to an orphan `badges` branch — no third-party coverage service. It runs on a schedule / manual dispatch only (never on push or pull requests, so it never gates merges). The engine's own meta-tests are excluded from the measured run to avoid double-instrumenting `src/coverage.sh` (#754)
- `--jobs auto` (and `-j auto`) caps parallel concurrency at the detected CPU core count, portable across Linux/macOS/BSD (`nproc`, then `sysctl`, then `getconf`, falling back to 4). The default stays unlimited (`--jobs 0`) (#766)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ bashunit ships ~60 assertions across many families. One representative example p

Full documentation, covering installation options, every feature and examples, lives at [bashunit.com](https://bashunit.com).

Shell tab-completion for bash and zsh is available under [`completions/`](completions/) — see the [installation docs](https://bashunit.com/installation#shell-completion).

## Contribute

Issues, ideas and pull requests are welcome.
Expand Down
115 changes: 115 additions & 0 deletions completions/_bashunit
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#compdef bashunit
# zsh completion for bashunit
#
# Install: copy this file as _bashunit into a directory in your $fpath, e.g.
# cp completions/_bashunit /usr/local/share/zsh/site-functions/_bashunit
# then restart zsh (or reinitialize completions with compinit).
#
# Kept in sync with src/main.sh by tests/unit/completions_test.sh (anti-drift).

_bashunit() {
local curcontext="$curcontext" ret=1

# `bashunit assert <fn>` completes the public assertion names.
if (( CURRENT == 3 )) && [ "${words[2]}" = "assert" ]; then
local -a assert_fns
assert_fns=(
assert_array_contains assert_array_length assert_array_not_contains
assert_arrays_equal assert_command_not_found assert_contains
assert_contains_ignore_case assert_date_after assert_date_before
assert_date_equals assert_date_within_delta assert_date_within_range
assert_directory_exists assert_directory_not_exists assert_duration
assert_duration_greater_than assert_duration_less_than assert_empty
assert_equals assert_exec assert_exit_code assert_false
assert_file_contains assert_file_exists assert_file_not_contains
assert_file_not_exists assert_file_permissions assert_files_equals
assert_files_not_equals assert_general_error assert_greater_or_equal_than
assert_greater_than assert_is_directory assert_is_directory_empty
assert_is_directory_not_empty assert_is_directory_not_readable
assert_is_directory_not_writable assert_is_directory_readable
assert_is_directory_writable assert_is_file assert_is_file_empty
assert_json_contains assert_json_equals assert_json_key_exists
assert_less_or_equal_than assert_less_than assert_line_count
assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches
assert_not_contains assert_not_empty assert_not_equals assert_not_matches
assert_not_same assert_same assert_string_ends_with
assert_string_matches_format assert_string_not_ends_with
assert_string_not_matches_format assert_string_not_starts_with
assert_string_starts_with assert_successful_code assert_true
assert_unsuccessful_code assert_within_delta
)
compadd -a assert_fns
return 0
fi

if (( CURRENT == 2 )); then
local -a subcommands
subcommands=(
'test:Run tests (default command)'
'bench:Run benchmarks'
'doc:Display assertion documentation'
'init:Initialize a new test directory'
'learn:Start interactive tutorial'
'upgrade:Upgrade bashunit to latest version'
'assert:Run standalone assertion'
'watch:Watch files and rerun tests on change'
)
_describe 'bashunit command' subcommands && ret=0
fi

_arguments \
'(-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:' \
'--exclude-tag[Skip tests with matching @tag]:tag:' \
'--log-junit[Write JUnit XML report]:file:_files' \
'--report-junit[Write JUnit XML report]:file:_files' \
'--log-gha[Write GitHub Actions workflow-commands log]:file:_files' \
'(-j --jobs)'{-j,--jobs}'[Max N parallel jobs, or auto]:jobs:(auto)' \
'(-p --parallel)'{-p,--parallel}'[Run tests in parallel]' \
'--no-parallel[Run tests sequentially]' \
'(-r --report-html)'{-r,--report-html}'[Write HTML report]:file:_files' \
'--report-tap[Write TAP version 13 report]:file:_files' \
'--report-json[Write machine-readable JSON report]:file:_files' \
'(-s --simple)'{-s,--simple}'[Simple output with dots]' \
'--detailed[Detailed output, the default]' \
'--output[Output format]:format:(tap)' \
'(-R --run-all)'{-R,--run-all}'[Run all assertions without stopping on first failure]' \
'(-S --stop-on-failure)'{-S,--stop-on-failure}'[Stop on first failure]' \
'--test-timeout[Fail a test running longer than N seconds]:seconds:' \
'--retry[Rerun a failed test up to N extra times]:count:' \
'--random-order[Randomize test execution order]' \
'--seed[Seed for random order]:seed:' \
'--shard[Run shard i of n]:shard:' \
'--rerun-failed[Replay only the tests that failed on the last run]' \
'(-vvv --verbose)'{-vvv,--verbose}'[Show execution details]' \
'--debug[Enable shell debug mode]::file:_files' \
'--no-output[Suppress all output]' \
'--failures-only[Only show failures]' \
'--fail-on-risky[Treat tests without assertions as failures]' \
'--profile[Report the slowest tests]' \
'--no-progress[Suppress real-time progress]' \
'--show-output[Show test output on failure]' \
'--no-output-on-failure[Hide test output on failure]' \
'--show-skipped[Show skipped tests summary]' \
'--show-incomplete[Show incomplete tests summary]' \
'--strict[Enable strict shell mode]' \
'--skip-env-file[Skip .env loading]' \
'(-l --login)'{-l,--login}'[Run tests in login shell context]' \
'(-w --watch)'{-w,--watch}'[Watch for changes and rerun tests]' \
'--no-color[Disable colored output]' \
'--coverage[Enable code coverage tracking]' \
'--coverage-paths[Paths to track]:paths:_files' \
'--coverage-exclude[Coverage exclusion patterns]:pattern:' \
'--coverage-report[LCOV output path]:file:_files' \
'--coverage-report-html[Generate HTML coverage report]::dir:_files' \
'--coverage-min[Minimum coverage threshold]:percent:' \
'--no-coverage-report[Console coverage output only]' \
'(-h --help)'{-h,--help}'[Show help message]' \
'*:path:_files' && ret=0

return ret
}

_bashunit "$@"
100 changes: 100 additions & 0 deletions completions/bashunit.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# bash completion for bashunit -*- shell-script -*-
#
# Install (bash-completion):
# cp completions/bashunit.bash /usr/local/etc/bash_completion.d/bashunit
# or source it from your ~/.bashrc:
# source /path/to/bashunit/completions/bashunit.bash
#
# Kept in sync with src/main.sh by tests/unit/completions_test.sh (anti-drift).
# Works on bash 3.2+ (plain compgen -W, no bash-completion helpers required).

_BASHUNIT_COMPLETIONS_SUBCOMMANDS="test bench doc init learn upgrade assert watch"

_BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --coverage --coverage-exclude \
--coverage-min --coverage-paths --coverage-report --coverage-report-html \
--debug --detailed --env --exclude-tag --fail-on-risky --failures-only \
--filter --help --jobs --log-gha --log-junit --login --no-color \
--no-coverage-report --no-output --no-output-on-failure --no-parallel \
--no-progress --output --parallel --profile --random-order --report-html \
--report-json --report-junit --report-tap --rerun-failed --retry --run-all \
--seed --shard --show-incomplete --show-output --show-skipped --simple \
--skip-env-file --stop-on-failure --strict --tag --test-timeout --verbose \
--watch -R -S -a -e -f -h -j -l -p -r -s -vvv -w"

_BASHUNIT_COMPLETIONS_ASSERT_FNS="assert_array_contains assert_array_length \
assert_array_not_contains assert_arrays_equal assert_command_not_found \
assert_contains assert_contains_ignore_case assert_date_after \
assert_date_before assert_date_equals assert_date_within_delta \
assert_date_within_range assert_directory_exists assert_directory_not_exists \
assert_duration assert_duration_greater_than assert_duration_less_than \
assert_empty assert_equals assert_exec assert_exit_code assert_false \
assert_file_contains assert_file_exists assert_file_not_contains \
assert_file_not_exists assert_file_permissions assert_files_equals \
assert_files_not_equals assert_general_error assert_greater_or_equal_than \
assert_greater_than assert_is_directory assert_is_directory_empty \
assert_is_directory_not_empty assert_is_directory_not_readable \
assert_is_directory_not_writable assert_is_directory_readable \
assert_is_directory_writable assert_is_file assert_is_file_empty \
assert_json_contains assert_json_equals assert_json_key_exists \
assert_less_or_equal_than assert_less_than assert_line_count \
assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches \
assert_not_contains assert_not_empty assert_not_equals assert_not_matches \
assert_not_same assert_same assert_string_ends_with \
assert_string_matches_format assert_string_not_ends_with \
assert_string_not_matches_format assert_string_not_starts_with \
assert_string_starts_with assert_successful_code assert_true \
assert_unsuccessful_code assert_within_delta"

# compgen output is split into COMPREPLY words on purpose; mapfile/read -a would need bash 4+.
# shellcheck disable=SC2207
_bashunit_completions() {
local cur prev
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD - 1]}"

# Value hints for options that take an argument.
case "$prev" in
--output)
COMPREPLY=($(compgen -W "tap" -- "$cur"))
return 0
;;
-j | --jobs)
COMPREPLY=($(compgen -W "auto" -- "$cur"))
return 0
;;
-e | --env | --boot)
COMPREPLY=($(compgen -f -- "$cur"))
return 0
;;
-f | --filter | --tag | --exclude-tag | --retry | --seed | --shard | --test-timeout)
return 0
;;
esac

# `bashunit assert <fn>` completes the public assertion names.
if [ "$COMP_CWORD" -ge 2 ] && [ "${COMP_WORDS[1]}" = "assert" ]; then
COMPREPLY=($(compgen -W "$_BASHUNIT_COMPLETIONS_ASSERT_FNS" -- "$cur"))
return 0
fi

# First word: subcommands (plus flags, since `test` is the default command).
if [ "$COMP_CWORD" -eq 1 ] && [ "${cur#-}" = "$cur" ]; then
COMPREPLY=($(compgen -W "$_BASHUNIT_COMPLETIONS_SUBCOMMANDS" -- "$cur"))
# Also offer test files/dirs, as `bashunit tests/` is the common shorthand.
COMPREPLY+=($(compgen -f -- "$cur"))
return 0
fi

case "$cur" in
-*)
COMPREPLY=($(compgen -W "$_BASHUNIT_COMPLETIONS_TEST_OPTS" -- "$cur"))
;;
*)
COMPREPLY=($(compgen -f -- "$cur"))
;;
esac
return 0
}

complete -o filenames -F _bashunit_completions bashunit 2>/dev/null || true
27 changes: 27 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,33 @@ Either way you get bashunit updates as routine pull requests — no manual re-pi
See bashunit's own pipeline for a real example: https://github.com/TypedDevs/bashunit/blob/main/.github/workflows/tests.yml
:::

## Shell completion

bashunit ships tab-completion scripts for bash and zsh under
[`completions/`](https://github.com/TypedDevs/bashunit/tree/main/completions)
— subcommands, all `test` flags (with value hints like `--jobs auto` and
`--output tap`), and the assertion names after `bashunit assert`.

::: code-group
```bash [bash]
# With bash-completion installed (path may vary by OS):
cp completions/bashunit.bash /usr/local/etc/bash_completion.d/bashunit

# Or source it directly from your ~/.bashrc:
source /path/to/bashunit/completions/bashunit.bash
```
```zsh [zsh]
# Copy into any directory in your $fpath, e.g.:
cp completions/_bashunit /usr/local/share/zsh/site-functions/_bashunit

# then restart zsh (or reinitialize completions):
autoload -Uz compinit && compinit
```
:::

The scripts are kept honest by an anti-drift test in CI: adding a flag to
bashunit without updating the completions fails the build.

## Related

- [Quickstart](/quickstart) - write and run your first test
Expand Down
107 changes: 107 additions & 0 deletions tests/unit/completions_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# shellcheck disable=SC2317

# Anti-drift contract: the static completion scripts under completions/ must
# stay in sync with the flags parsed by the test subcommand in src/main.sh,
# the subcommand list in the bashunit entrypoint, and the public assertions.

BASH_COMPLETION_FILE="completions/bashunit.bash"
ZSH_COMPLETION_FILE="completions/_bashunit"

# Flags accepted by cmd_test, straight from the option-parsing case arms.
function completions_expected_test_flags() {
awk '/# Parse test-specific options/,/^ done$/' src/main.sh |
grep -E '^[[:space:]]+--?[a-zA-Z][a-zA-Z0-9-]*( \| --?[a-zA-Z][a-zA-Z0-9-]*)*\)' |
sed 's/)$//' | tr -d ' ' | tr '|' '\n' |
LC_ALL=C sort -u
}

function completions_expected_assert_functions() {
grep -hoE '^function assert_[a-z_0-9]+' src/assert*.sh |
sed 's/^function //' | LC_ALL=C sort -u
}

# Flags advertised by the bash completion script (single source variable).
function completions_bash_flags() {
(
# shellcheck source=/dev/null
source "$BASH_COMPLETION_FILE" 2>/dev/null
echo "$_BASHUNIT_COMPLETIONS_TEST_OPTS" | tr ' ' '\n' | grep -v '^$' | LC_ALL=C sort -u
)
}

# Flags advertised by the zsh completion script: strip [descriptions], then
# collect every -x/--long token.
function completions_zsh_flags() {
# Each punctuation char maps to a space (char-by-char); the repeated spaces are intentional.
# shellcheck disable=SC2020
sed 's/\[[^]]*\]//g' "$ZSH_COMPLETION_FILE" |
tr '{}(),"'"'"':' ' ' | tr ' \t' '\n\n' |
grep -E '^--?[a-zA-Z][a-zA-Z0-9-]*$' |
LC_ALL=C sort -u
}

function test_bash_completion_script_exists_and_passes_syntax_check() {
assert_file_exists "$BASH_COMPLETION_FILE"
assert_successful_code "$(bash -n "$BASH_COMPLETION_FILE" 2>&1)"
}

function test_zsh_completion_script_exists_and_passes_syntax_check() {
assert_file_exists "$ZSH_COMPLETION_FILE"
if ! command -v zsh >/dev/null 2>&1; then
bashunit::skip "zsh not available" && return
fi
assert_successful_code "$(zsh -n "$ZSH_COMPLETION_FILE" 2>&1)"
}

function test_bash_completion_flags_match_main_sh() {
local expected actual
expected=$(completions_expected_test_flags)
actual=$(completions_bash_flags)

assert_same "$expected" "$actual"
}

function test_zsh_completion_flags_match_main_sh() {
local expected actual
expected=$(completions_expected_test_flags)
actual=$(completions_zsh_flags)

assert_same "$expected" "$actual"
}

function test_bash_completion_lists_all_subcommands() {
local subcommands
subcommands=$(
# shellcheck source=/dev/null
source "$BASH_COMPLETION_FILE" 2>/dev/null
echo "$_BASHUNIT_COMPLETIONS_SUBCOMMANDS"
)

local sub
for sub in test bench doc init learn upgrade assert watch; do
assert_contains "$sub" "$subcommands"
done
}

function test_zsh_completion_lists_all_subcommands() {
local content
content=$(cat "$ZSH_COMPLETION_FILE")

local sub
for sub in test bench doc init learn upgrade assert watch; do
assert_contains "$sub" "$content"
done
}

function test_bash_completion_assert_functions_match_src() {
local expected actual
expected=$(completions_expected_assert_functions)
actual=$(
# shellcheck source=/dev/null
source "$BASH_COMPLETION_FILE" 2>/dev/null
echo "$_BASHUNIT_COMPLETIONS_ASSERT_FNS" | tr ' ' '\n' | grep -v '^$' | LC_ALL=C sort -u
)

assert_same "$expected" "$actual"
}
Loading