From c1e56f96c1f248d04f52fe953f8fd1760b6aecef Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 14 Jul 2026 11:53:32 +0200 Subject: [PATCH] feat(watch): pure-shell polling fallback when inotifywait/fswatch are missing The 'watch' subcommand hard-failed when neither watcher was installed. It now degrades to a pure-shell polling loop (find -newer sentinel), prints a one-line notice, and reruns on created/modified .sh files. Interval configurable via BASHUNIT_WATCH_INTERVAL (default 2s, positive integer, invalid values fall back to default). Closes #779 --- CHANGELOG.md | 1 + docs/command-line.md | 19 ++++++---- docs/configuration.md | 14 +++++++ src/env.sh | 19 ++++++++++ src/watch.sh | 47 +++++++++++++++++++----- tests/unit/watch_polling_test.sh | 63 ++++++++++++++++++++++++++++++++ tests/unit/watch_test.sh | 40 +++++++++----------- 7 files changed, 162 insertions(+), 41 deletions(-) create mode 100644 tests/unit/watch_polling_test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 3842a5b9..3a3355d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `:` 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) diff --git a/docs/command-line.md b/docs/command-line.md index 475a60c7..2fbab7e2 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -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 @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index d321c81b..c79195a1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -215,6 +215,20 @@ BASHUNIT_STOP_ON_ASSERTION_FAILURE=true ``` ::: +## Watch polling interval + +> `BASHUNIT_WATCH_INTERVAL=` + +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` diff --git a/src/env.sh b/src/env.sh index 39151d9e..426af435 100644 --- a/src/env.sh +++ b/src/env.sh @@ -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 @@ -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}}" diff --git a/src/watch.sh b/src/watch.sh index 7e8c8b36..0ed35529 100644 --- a/src/watch.sh +++ b/src/watch.sh @@ -14,7 +14,7 @@ function bashunit::watch::is_available() { elif bashunit::watch::_command_exists fswatch; then echo "fswatch" else - echo "" + echo "polling" fi } @@ -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[@]}"}" @@ -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 \ diff --git a/tests/unit/watch_polling_test.sh b/tests/unit/watch_polling_test.sh new file mode 100644 index 00000000..814a5f89 --- /dev/null +++ b/tests/unit/watch_polling_test.sh @@ -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)" +} diff --git a/tests/unit/watch_test.sh b/tests/unit/watch_test.sh index 442f1ee1..c11ba855 100644 --- a/tests/unit/watch_test.sh +++ b/tests/unit/watch_test.sh @@ -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" } ############################