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
- The `watch` subcommand no longer fails when neither `inotifywait` nor `fswatch` is installed: it degrades to a pure-shell polling loop (using `find -newer`) and prints a one-line notice instead of exiting. Polling checks every `BASHUNIT_WATCH_INTERVAL` seconds (default `2`, positive integer); created/modified `.sh` files trigger a rerun, deletions are not detected on this fallback path. Installing a watcher still gives instant, event-driven triggers (#779)
- 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)
Expand Down
19 changes: 11 additions & 8 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,13 @@ Dedicated watch subcommand that uses **OS file-event notifications** (no
polling) to re-run tests as soon as a `.sh` file changes. Any option accepted
by `bashunit test` is also accepted here.

When neither `inotifywait` nor `fswatch` is installed, it no longer fails:
it falls back to a **pure-shell polling loop** and prints a one-line notice.
Polling checks every `BASHUNIT_WATCH_INTERVAL` seconds (default `2`) using
`find -newer`, so it detects created and modified `.sh` files; deleted files
are not detected on the fallback path. Install one of the tools above for
instant, event-driven triggers.

::: code-group
```bash [Examples]
# Watch current directory
Expand All @@ -793,17 +800,13 @@ bashunit watch tests/ --simple
```
:::

::: warning Requirements
::: tip Recommended for instant triggers
- **Linux:** `inotifywait` (`sudo apt install inotify-tools`)
- **macOS:** `fswatch` (`brew install fswatch`)

If the required tool is not installed, bashunit prints a clear installation hint
and exits with a non-zero code.
:::

::: tip
If you cannot install `inotifywait` or `fswatch`, use the portable
[`-w/--watch`](#watch-mode) flag on `bashunit test` instead (uses polling).
Without either tool, bashunit degrades to polling (see above) instead of
failing. The portable [`-w/--watch`](#watch-mode) flag on `bashunit test`
also uses polling.
:::

## doc
Expand Down
14 changes: 14 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,20 @@ BASHUNIT_STOP_ON_ASSERTION_FAILURE=true
```
:::

## Watch polling interval

> `BASHUNIT_WATCH_INTERVAL=<seconds>`

Seconds between checks for the pure-shell polling loop used by watch mode when
neither `inotifywait` nor `fswatch` is installed. `2` by default. Must be a
positive integer; any other value falls back to the default.

::: code-group
```bash [Poll every 5 seconds]
BASHUNIT_WATCH_INTERVAL=5
```
:::

## Show header

> `BASHUNIT_SHOW_HEADER=true|false`
Expand Down
19 changes: 19 additions & 0 deletions src/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ function bashunit::env::load_config_file() {
done <"$file"
}

##
# Echoes $1 when it is a positive integer, otherwise echoes the default $2.
# Arguments: $1 candidate value, $2 fallback default
##
function bashunit::env::positive_int_or_default() {
local value="$1"
local default="$2"
case "$value" in
'' | *[!0-9]* | 0) echo "$default" ;;
*) echo "$value" ;;
esac
}

# Load project config (lower precedence than env vars, .env and CLI flags).
# Load .env file (skip if --skip-env-file is used to keep shell environment intact)
if [ "${BASHUNIT_SKIP_ENV_FILE:-false}" != "true" ]; then
Expand Down Expand Up @@ -84,6 +97,12 @@ _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH="80"
: "${BASHUNIT_REPORT_TAP:=${REPORT_TAP:=$_BASHUNIT_DEFAULT_REPORT_TAP}}"
: "${BASHUNIT_REPORT_JSON:=${REPORT_JSON:=$_BASHUNIT_DEFAULT_REPORT_JSON}}"

# Watch mode polling interval (seconds) used by the pure-shell fallback
_BASHUNIT_DEFAULT_WATCH_INTERVAL="2"
: "${BASHUNIT_WATCH_INTERVAL:=${WATCH_INTERVAL:=$_BASHUNIT_DEFAULT_WATCH_INTERVAL}}"
BASHUNIT_WATCH_INTERVAL=$(bashunit::env::positive_int_or_default \
"$BASHUNIT_WATCH_INTERVAL" "$_BASHUNIT_DEFAULT_WATCH_INTERVAL")

# Coverage
: "${BASHUNIT_COVERAGE:=${COVERAGE:=$_BASHUNIT_DEFAULT_COVERAGE}}"
: "${BASHUNIT_COVERAGE_PATHS:=${COVERAGE_PATHS:=$_BASHUNIT_DEFAULT_COVERAGE_PATHS}}"
Expand Down
47 changes: 37 additions & 10 deletions src/watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function bashunit::watch::is_available() {
elif bashunit::watch::_command_exists fswatch; then
echo "fswatch"
else
echo ""
echo "polling"
fi
}

Expand All @@ -29,17 +29,13 @@ function bashunit::watch::run() {
local tool
tool=$(bashunit::watch::is_available)

if [ -z "$tool" ]; then
printf "%sError: watch mode requires 'inotifywait' (Linux) or 'fswatch' (macOS).%s\n" \
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}"
printf " Linux: sudo apt install inotify-tools\n"
printf " macOS: brew install fswatch\n"
exit 1
if [ "$tool" = "polling" ]; then
bashunit::watch::_print_polling_notice "$path"
else
printf "%sbashunit --watch%s watching: %s\n\n" \
"${_BASHUNIT_COLOR_PASSED}" "${_BASHUNIT_COLOR_DEFAULT}" "$path"
fi

printf "%sbashunit --watch%s watching: %s\n\n" \
"${_BASHUNIT_COLOR_PASSED}" "${_BASHUNIT_COLOR_DEFAULT}" "$path"

# Run once immediately before entering the watch loop
bashunit::watch::run_tests "$path" "${extra_args[@]+"${extra_args[@]}"}"

Expand All @@ -59,11 +55,42 @@ function bashunit::watch::run_tests() {
return $?
}

function bashunit::watch::_print_polling_notice() {
local path="$1"
printf "%sbashunit --watch%s polling: %s (every %ss)\n\n" \
"${_BASHUNIT_COLOR_PASSED}" "${_BASHUNIT_COLOR_DEFAULT}" \
"$path" "${BASHUNIT_WATCH_INTERVAL:-2}"
printf " No 'inotifywait' or 'fswatch' found; using pure-shell polling.\n"
printf " Install one for instant triggers:\n"
printf " Linux: sudo apt install inotify-tools\n"
printf " macOS: brew install fswatch\n\n"
}

# Lists watched *.sh files modified since the sentinel file was touched.
# Non-empty output means a rerun is due. `find -newer` is POSIX and avoids the
# GNU/BSD `stat` flag divergence.
function bashunit::watch::_poll_changes() {
local sentinel="$1"
local path="$2"
find "$path" -name '*.sh' -newer "$sentinel" -print 2>/dev/null
}

function bashunit::watch::wait_for_change() {
local tool="$1"
local path="$2"

case "$tool" in
polling)
local sentinel
sentinel="$(bashunit::temp_dir watch)/sentinel"
while true; do
: >"$sentinel"
sleep "${BASHUNIT_WATCH_INTERVAL:-2}"
if [ -n "$(bashunit::watch::_poll_changes "$sentinel" "$path")" ]; then
return 0
fi
done
;;
inotifywait)
inotifywait \
--quiet \
Expand Down
63 changes: 63 additions & 0 deletions tests/unit/watch_polling_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bash

# shellcheck disable=SC2329 # Test functions are invoked indirectly by bashunit

############################
# bashunit::watch::_poll_changes
############################

function test_poll_changes_detects_sh_file_newer_than_sentinel() {
local dir
dir=$(bashunit::temp_dir watch_poll_new)
local sentinel="$dir/.sentinel"
: >"$sentinel"
touch -t 202001010000 "$sentinel"
: >"$dir/foo.sh"
touch -t 202501010000 "$dir/foo.sh"

assert_not_empty "$(bashunit::watch::_poll_changes "$sentinel" "$dir")"
}

function test_poll_changes_reports_nothing_when_no_sh_changed() {
local dir
dir=$(bashunit::temp_dir watch_poll_none)
: >"$dir/foo.sh"
touch -t 202001010000 "$dir/foo.sh"
local sentinel="$dir/.sentinel"
: >"$sentinel"
touch -t 202501010000 "$sentinel"

assert_empty "$(bashunit::watch::_poll_changes "$sentinel" "$dir")"
}

function test_poll_changes_ignores_non_sh_files() {
local dir
dir=$(bashunit::temp_dir watch_poll_nonsh)
local sentinel="$dir/.sentinel"
: >"$sentinel"
touch -t 202001010000 "$sentinel"
: >"$dir/note.txt"
touch -t 202501010000 "$dir/note.txt"

assert_empty "$(bashunit::watch::_poll_changes "$sentinel" "$dir")"
}

############################
# bashunit::env::positive_int_or_default
############################

function test_positive_int_or_default_keeps_valid_integer() {
assert_equals "5" "$(bashunit::env::positive_int_or_default 5 2)"
}

function test_positive_int_or_default_falls_back_on_zero() {
assert_equals "2" "$(bashunit::env::positive_int_or_default 0 2)"
}

function test_positive_int_or_default_falls_back_on_non_numeric() {
assert_equals "2" "$(bashunit::env::positive_int_or_default abc 2)"
}

function test_positive_int_or_default_falls_back_on_empty() {
assert_equals "2" "$(bashunit::env::positive_int_or_default '' 2)"
}
40 changes: 17 additions & 23 deletions tests/unit/watch_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,44 +21,38 @@ function test_is_available_returns_fswatch_when_inotifywait_missing() {
assert_equals "fswatch" "$(bashunit::watch::is_available)"
}

function test_is_available_returns_empty_when_no_tool_found() {
function test_is_available_returns_polling_when_no_tool_found() {
bashunit::mock bashunit::watch::_command_exists mock_false

assert_empty "$(bashunit::watch::is_available)"
assert_equals "polling" "$(bashunit::watch::is_available)"
}

############################
# bashunit::watch::run β€” error path (no tool)
# run() calls exit 1, so we must capture it in a subshell
# bashunit::watch::run β€” polling fallback (no inotifywait/fswatch)
# run() loops forever, so mock wait_for_change to exit and break the loop.
############################

function test_run_exits_nonzero_when_no_tool_available() {
bashunit::mock bashunit::watch::is_available echo ""

local exit_code=0
(bashunit::watch::run "tests/" >/dev/null 2>&1) || exit_code=$?

assert_greater_than "0" "$exit_code"
}

function test_run_error_message_mentions_required_tools() {
bashunit::mock bashunit::watch::is_available echo ""
function test_run_falls_back_to_polling_when_no_tool() {
bashunit::mock bashunit::watch::is_available echo "polling"
bashunit::mock bashunit::watch::run_tests true
function bashunit::watch::wait_for_change() { exit 0; }

local output
output=$(bashunit::watch::run "tests/" 2>&1) || true
output=$( (bashunit::watch::run "tests/") 2>&1)

assert_contains "inotifywait" "$output"
assert_contains "fswatch" "$output"
assert_contains "polling" "$output"
}

function test_run_error_message_includes_install_hints() {
bashunit::mock bashunit::watch::is_available echo ""
function test_run_polling_notice_keeps_install_hints() {
bashunit::mock bashunit::watch::is_available echo "polling"
bashunit::mock bashunit::watch::run_tests true
function bashunit::watch::wait_for_change() { exit 0; }

local output
output=$(bashunit::watch::run "tests/" 2>&1) || true
output=$( (bashunit::watch::run "tests/") 2>&1)

assert_contains "apt install inotify-tools" "$output"
assert_contains "brew install fswatch" "$output"
assert_contains "inotify-tools" "$output"
assert_contains "fswatch" "$output"
}

############################
Expand Down
Loading