Skip to content

Commit 49064ed

Browse files
authored
feat(watch): pure-shell polling fallback when watchers are missing (#793)
1 parent 3595a7c commit 49064ed

7 files changed

Lines changed: 162 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- `--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
77

88
### Added
9+
- 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)
910
- 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)
1011
- `--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)
1112
- 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)

docs/command-line.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,13 @@ Dedicated watch subcommand that uses **OS file-event notifications** (no
777777
polling) to re-run tests as soon as a `.sh` file changes. Any option accepted
778778
by `bashunit test` is also accepted here.
779779

780+
When neither `inotifywait` nor `fswatch` is installed, it no longer fails:
781+
it falls back to a **pure-shell polling loop** and prints a one-line notice.
782+
Polling checks every `BASHUNIT_WATCH_INTERVAL` seconds (default `2`) using
783+
`find -newer`, so it detects created and modified `.sh` files; deleted files
784+
are not detected on the fallback path. Install one of the tools above for
785+
instant, event-driven triggers.
786+
780787
::: code-group
781788
```bash [Examples]
782789
# Watch current directory
@@ -793,17 +800,13 @@ bashunit watch tests/ --simple
793800
```
794801
:::
795802

796-
::: warning Requirements
803+
::: tip Recommended for instant triggers
797804
- **Linux:** `inotifywait` (`sudo apt install inotify-tools`)
798805
- **macOS:** `fswatch` (`brew install fswatch`)
799806

800-
If the required tool is not installed, bashunit prints a clear installation hint
801-
and exits with a non-zero code.
802-
:::
803-
804-
::: tip
805-
If you cannot install `inotifywait` or `fswatch`, use the portable
806-
[`-w/--watch`](#watch-mode) flag on `bashunit test` instead (uses polling).
807+
Without either tool, bashunit degrades to polling (see above) instead of
808+
failing. The portable [`-w/--watch`](#watch-mode) flag on `bashunit test`
809+
also uses polling.
807810
:::
808811

809812
## doc

docs/configuration.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,20 @@ BASHUNIT_STOP_ON_ASSERTION_FAILURE=true
215215
```
216216
:::
217217

218+
## Watch polling interval
219+
220+
> `BASHUNIT_WATCH_INTERVAL=<seconds>`
221+
222+
Seconds between checks for the pure-shell polling loop used by watch mode when
223+
neither `inotifywait` nor `fswatch` is installed. `2` by default. Must be a
224+
positive integer; any other value falls back to the default.
225+
226+
::: code-group
227+
```bash [Poll every 5 seconds]
228+
BASHUNIT_WATCH_INTERVAL=5
229+
```
230+
:::
231+
218232
## Show header
219233

220234
> `BASHUNIT_SHOW_HEADER=true|false`

src/env.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ function bashunit::env::load_config_file() {
4545
done <"$file"
4646
}
4747

48+
##
49+
# Echoes $1 when it is a positive integer, otherwise echoes the default $2.
50+
# Arguments: $1 candidate value, $2 fallback default
51+
##
52+
function bashunit::env::positive_int_or_default() {
53+
local value="$1"
54+
local default="$2"
55+
case "$value" in
56+
'' | *[!0-9]* | 0) echo "$default" ;;
57+
*) echo "$value" ;;
58+
esac
59+
}
60+
4861
# Load project config (lower precedence than env vars, .env and CLI flags).
4962
# Load .env file (skip if --skip-env-file is used to keep shell environment intact)
5063
if [ "${BASHUNIT_SKIP_ENV_FILE:-false}" != "true" ]; then
@@ -84,6 +97,12 @@ _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH="80"
8497
: "${BASHUNIT_REPORT_TAP:=${REPORT_TAP:=$_BASHUNIT_DEFAULT_REPORT_TAP}}"
8598
: "${BASHUNIT_REPORT_JSON:=${REPORT_JSON:=$_BASHUNIT_DEFAULT_REPORT_JSON}}"
8699

100+
# Watch mode polling interval (seconds) used by the pure-shell fallback
101+
_BASHUNIT_DEFAULT_WATCH_INTERVAL="2"
102+
: "${BASHUNIT_WATCH_INTERVAL:=${WATCH_INTERVAL:=$_BASHUNIT_DEFAULT_WATCH_INTERVAL}}"
103+
BASHUNIT_WATCH_INTERVAL=$(bashunit::env::positive_int_or_default \
104+
"$BASHUNIT_WATCH_INTERVAL" "$_BASHUNIT_DEFAULT_WATCH_INTERVAL")
105+
87106
# Coverage
88107
: "${BASHUNIT_COVERAGE:=${COVERAGE:=$_BASHUNIT_DEFAULT_COVERAGE}}"
89108
: "${BASHUNIT_COVERAGE_PATHS:=${COVERAGE_PATHS:=$_BASHUNIT_DEFAULT_COVERAGE_PATHS}}"

src/watch.sh

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function bashunit::watch::is_available() {
1414
elif bashunit::watch::_command_exists fswatch; then
1515
echo "fswatch"
1616
else
17-
echo ""
17+
echo "polling"
1818
fi
1919
}
2020

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

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

40-
printf "%sbashunit --watch%s watching: %s\n\n" \
41-
"${_BASHUNIT_COLOR_PASSED}" "${_BASHUNIT_COLOR_DEFAULT}" "$path"
42-
4339
# Run once immediately before entering the watch loop
4440
bashunit::watch::run_tests "$path" "${extra_args[@]+"${extra_args[@]}"}"
4541

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

58+
function bashunit::watch::_print_polling_notice() {
59+
local path="$1"
60+
printf "%sbashunit --watch%s polling: %s (every %ss)\n\n" \
61+
"${_BASHUNIT_COLOR_PASSED}" "${_BASHUNIT_COLOR_DEFAULT}" \
62+
"$path" "${BASHUNIT_WATCH_INTERVAL:-2}"
63+
printf " No 'inotifywait' or 'fswatch' found; using pure-shell polling.\n"
64+
printf " Install one for instant triggers:\n"
65+
printf " Linux: sudo apt install inotify-tools\n"
66+
printf " macOS: brew install fswatch\n\n"
67+
}
68+
69+
# Lists watched *.sh files modified since the sentinel file was touched.
70+
# Non-empty output means a rerun is due. `find -newer` is POSIX and avoids the
71+
# GNU/BSD `stat` flag divergence.
72+
function bashunit::watch::_poll_changes() {
73+
local sentinel="$1"
74+
local path="$2"
75+
find "$path" -name '*.sh' -newer "$sentinel" -print 2>/dev/null
76+
}
77+
6278
function bashunit::watch::wait_for_change() {
6379
local tool="$1"
6480
local path="$2"
6581

6682
case "$tool" in
83+
polling)
84+
local sentinel
85+
sentinel="$(bashunit::temp_dir watch)/sentinel"
86+
while true; do
87+
: >"$sentinel"
88+
sleep "${BASHUNIT_WATCH_INTERVAL:-2}"
89+
if [ -n "$(bashunit::watch::_poll_changes "$sentinel" "$path")" ]; then
90+
return 0
91+
fi
92+
done
93+
;;
6794
inotifywait)
6895
inotifywait \
6996
--quiet \

tests/unit/watch_polling_test.sh

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env bash
2+
3+
# shellcheck disable=SC2329 # Test functions are invoked indirectly by bashunit
4+
5+
############################
6+
# bashunit::watch::_poll_changes
7+
############################
8+
9+
function test_poll_changes_detects_sh_file_newer_than_sentinel() {
10+
local dir
11+
dir=$(bashunit::temp_dir watch_poll_new)
12+
local sentinel="$dir/.sentinel"
13+
: >"$sentinel"
14+
touch -t 202001010000 "$sentinel"
15+
: >"$dir/foo.sh"
16+
touch -t 202501010000 "$dir/foo.sh"
17+
18+
assert_not_empty "$(bashunit::watch::_poll_changes "$sentinel" "$dir")"
19+
}
20+
21+
function test_poll_changes_reports_nothing_when_no_sh_changed() {
22+
local dir
23+
dir=$(bashunit::temp_dir watch_poll_none)
24+
: >"$dir/foo.sh"
25+
touch -t 202001010000 "$dir/foo.sh"
26+
local sentinel="$dir/.sentinel"
27+
: >"$sentinel"
28+
touch -t 202501010000 "$sentinel"
29+
30+
assert_empty "$(bashunit::watch::_poll_changes "$sentinel" "$dir")"
31+
}
32+
33+
function test_poll_changes_ignores_non_sh_files() {
34+
local dir
35+
dir=$(bashunit::temp_dir watch_poll_nonsh)
36+
local sentinel="$dir/.sentinel"
37+
: >"$sentinel"
38+
touch -t 202001010000 "$sentinel"
39+
: >"$dir/note.txt"
40+
touch -t 202501010000 "$dir/note.txt"
41+
42+
assert_empty "$(bashunit::watch::_poll_changes "$sentinel" "$dir")"
43+
}
44+
45+
############################
46+
# bashunit::env::positive_int_or_default
47+
############################
48+
49+
function test_positive_int_or_default_keeps_valid_integer() {
50+
assert_equals "5" "$(bashunit::env::positive_int_or_default 5 2)"
51+
}
52+
53+
function test_positive_int_or_default_falls_back_on_zero() {
54+
assert_equals "2" "$(bashunit::env::positive_int_or_default 0 2)"
55+
}
56+
57+
function test_positive_int_or_default_falls_back_on_non_numeric() {
58+
assert_equals "2" "$(bashunit::env::positive_int_or_default abc 2)"
59+
}
60+
61+
function test_positive_int_or_default_falls_back_on_empty() {
62+
assert_equals "2" "$(bashunit::env::positive_int_or_default '' 2)"
63+
}

tests/unit/watch_test.sh

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,44 +21,38 @@ function test_is_available_returns_fswatch_when_inotifywait_missing() {
2121
assert_equals "fswatch" "$(bashunit::watch::is_available)"
2222
}
2323

24-
function test_is_available_returns_empty_when_no_tool_found() {
24+
function test_is_available_returns_polling_when_no_tool_found() {
2525
bashunit::mock bashunit::watch::_command_exists mock_false
2626

27-
assert_empty "$(bashunit::watch::is_available)"
27+
assert_equals "polling" "$(bashunit::watch::is_available)"
2828
}
2929

3030
############################
31-
# bashunit::watch::run — error path (no tool)
32-
# run() calls exit 1, so we must capture it in a subshell
31+
# bashunit::watch::run — polling fallback (no inotifywait/fswatch)
32+
# run() loops forever, so mock wait_for_change to exit and break the loop.
3333
############################
3434

35-
function test_run_exits_nonzero_when_no_tool_available() {
36-
bashunit::mock bashunit::watch::is_available echo ""
37-
38-
local exit_code=0
39-
(bashunit::watch::run "tests/" >/dev/null 2>&1) || exit_code=$?
40-
41-
assert_greater_than "0" "$exit_code"
42-
}
43-
44-
function test_run_error_message_mentions_required_tools() {
45-
bashunit::mock bashunit::watch::is_available echo ""
35+
function test_run_falls_back_to_polling_when_no_tool() {
36+
bashunit::mock bashunit::watch::is_available echo "polling"
37+
bashunit::mock bashunit::watch::run_tests true
38+
function bashunit::watch::wait_for_change() { exit 0; }
4639

4740
local output
48-
output=$(bashunit::watch::run "tests/" 2>&1) || true
41+
output=$( (bashunit::watch::run "tests/") 2>&1)
4942

50-
assert_contains "inotifywait" "$output"
51-
assert_contains "fswatch" "$output"
43+
assert_contains "polling" "$output"
5244
}
5345

54-
function test_run_error_message_includes_install_hints() {
55-
bashunit::mock bashunit::watch::is_available echo ""
46+
function test_run_polling_notice_keeps_install_hints() {
47+
bashunit::mock bashunit::watch::is_available echo "polling"
48+
bashunit::mock bashunit::watch::run_tests true
49+
function bashunit::watch::wait_for_change() { exit 0; }
5650

5751
local output
58-
output=$(bashunit::watch::run "tests/" 2>&1) || true
52+
output=$( (bashunit::watch::run "tests/") 2>&1)
5953

60-
assert_contains "apt install inotify-tools" "$output"
61-
assert_contains "brew install fswatch" "$output"
54+
assert_contains "inotify-tools" "$output"
55+
assert_contains "fswatch" "$output"
6256
}
6357

6458
############################

0 commit comments

Comments
 (0)