Skip to content

Commit d1c5a84

Browse files
authored
feat: add watch mode subcommand (#596)
1 parent 33a113e commit d1c5a84

8 files changed

Lines changed: 226 additions & 1 deletion

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
## Unreleased
44

55
### Added
6+
- Add `watch [path]` subcommand to re-run tests automatically on file changes
7+
- Uses `inotifywait` on Linux (via `inotify-tools`) or `fswatch` on macOS
8+
- Falls back with a clear install hint if neither tool is available
9+
- Accepts optional path argument (defaults to current directory)
10+
611
- Add date comparison assertions: `assert_date_equals`, `assert_date_before`, `assert_date_after`, `assert_date_within_range`, `assert_date_within_delta`
712
- Auto-detects epoch seconds, ISO 8601, space-separated datetime, and timezone offsets
813
- Mixed formats supported in the same assertion call

bashunit

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ source "$BASHUNIT_ROOT_DIR/src/console_results.sh"
7272
source "$BASHUNIT_ROOT_DIR/src/helpers.sh"
7373
source "$BASHUNIT_ROOT_DIR/src/test_title.sh"
7474
source "$BASHUNIT_ROOT_DIR/src/upgrade.sh"
75+
source "$BASHUNIT_ROOT_DIR/src/watch.sh"
7576
source "$BASHUNIT_ROOT_DIR/src/assertions.sh"
7677
source "$BASHUNIT_ROOT_DIR/src/doc.sh"
7778
source "$BASHUNIT_ROOT_DIR/src/reports.sh"
@@ -88,7 +89,7 @@ bashunit::clock::init
8889
_SUBCOMMAND=""
8990

9091
case "${1:-}" in
91-
test | bench | doc | init | learn | upgrade | assert)
92+
test | bench | doc | init | learn | upgrade | assert | watch)
9293
_SUBCOMMAND="$1"
9394
shift
9495
;;
@@ -123,4 +124,5 @@ case "$_SUBCOMMAND" in
123124
learn) bashunit::main::cmd_learn "$@" ;;
124125
upgrade) bashunit::main::cmd_upgrade "$@" ;;
125126
assert) bashunit::main::cmd_assert "$@" ;;
127+
watch) bashunit::main::cmd_watch "$@" ;;
126128
esac

src/console_header.sh

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Commands:
6969
doc [filter] Display assertion documentation
7070
init [dir] Initialize a new test directory
7171
learn Start interactive tutorial
72+
watch [path] Watch files and re-run tests on change
7273
upgrade Upgrade bashunit to latest version
7374
7475
Global Options:
@@ -271,3 +272,28 @@ Note: You can also use 'bashunit test --assert <fn> <args>' (deprecated).
271272
More info: https://bashunit.typeddevs.com/standalone
272273
EOF
273274
}
275+
276+
function bashunit::console_header::print_watch_help() {
277+
cat << 'ENDOFHELP'
278+
Usage: bashunit watch [path] [test-options]
279+
280+
Watch .sh files for changes and automatically re-run tests.
281+
282+
Arguments:
283+
[path] Directory or file to watch and test (default: .)
284+
285+
Options:
286+
-h, --help Show this help message
287+
Any option accepted by 'bashunit test' is also accepted here.
288+
289+
Requirements:
290+
Linux: inotifywait (sudo apt install inotify-tools)
291+
macOS: fswatch (brew install fswatch)
292+
293+
Examples:
294+
bashunit watch Watch current directory
295+
bashunit watch tests/ Watch the tests/ directory
296+
bashunit watch tests/ --filter user Watch and filter by name
297+
bashunit watch tests/ --simple Watch with simple output
298+
ENDOFHELP
299+
}

src/main.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,22 @@ function bashunit::main::cmd_learn() {
405405
exit 0
406406
}
407407

408+
#############################
409+
# Subcommand: watch
410+
#############################
411+
function bashunit::main::cmd_watch() {
412+
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
413+
bashunit::console_header::print_watch_help
414+
exit 0
415+
fi
416+
417+
local path="${1:-.}"
418+
shift || true
419+
local -a extra_args=("$@")
420+
421+
bashunit::watch::run "$path" "${extra_args[@]+\"${extra_args[@]}\"}"
422+
}
423+
408424
#############################
409425
# Subcommand: upgrade
410426
#############################

src/watch.sh

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bash
2+
3+
# bashunit watch mode
4+
# Watches test and source files for changes and re-runs tests automatically.
5+
# Requires: inotifywait (inotify-tools) on Linux, or fswatch on macOS.
6+
7+
function bashunit::watch::_command_exists() {
8+
command -v "$1" &>/dev/null
9+
}
10+
11+
function bashunit::watch::is_available() {
12+
if bashunit::watch::_command_exists inotifywait; then
13+
echo "inotifywait"
14+
elif bashunit::watch::_command_exists fswatch; then
15+
echo "fswatch"
16+
else
17+
echo ""
18+
fi
19+
}
20+
21+
function bashunit::watch::run() {
22+
local path="${1:-.}"
23+
shift
24+
local extra_args=("$@")
25+
26+
local tool
27+
tool=$(bashunit::watch::is_available)
28+
29+
if [[ -z "$tool" ]]; then
30+
printf "%sError: watch mode requires 'inotifywait' (Linux) or 'fswatch' (macOS).%s\n" \
31+
"${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}"
32+
printf " Linux: sudo apt install inotify-tools\n"
33+
printf " macOS: brew install fswatch\n"
34+
exit 1
35+
fi
36+
37+
printf "%sbashunit --watch%s watching: %s\n\n" \
38+
"${_BASHUNIT_COLOR_PASSED}" "${_BASHUNIT_COLOR_DEFAULT}" "$path"
39+
40+
# Run once immediately before entering the watch loop
41+
bashunit::watch::run_tests "$path" "${extra_args[@]+"${extra_args[@]}"}"
42+
43+
while true; do
44+
bashunit::watch::wait_for_change "$tool" "$path"
45+
printf "\n%s[change detected — re-running tests]%s\n\n" \
46+
"${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}"
47+
bashunit::watch::run_tests "$path" "${extra_args[@]+"${extra_args[@]}"}"
48+
done
49+
}
50+
51+
function bashunit::watch::run_tests() {
52+
local path="$1"
53+
shift
54+
# Re-invoke bashunit test in a subshell so state resets cleanly each run
55+
"$BASHUNIT_ROOT_DIR/bashunit" test "$path" "$@"
56+
return $?
57+
}
58+
59+
function bashunit::watch::wait_for_change() {
60+
local tool="$1"
61+
local path="$2"
62+
63+
case "$tool" in
64+
inotifywait)
65+
inotifywait \
66+
--quiet \
67+
--recursive \
68+
--event modify,create,delete,move \
69+
--include '.*\.sh$' \
70+
"$path" 2>/dev/null
71+
;;
72+
fswatch)
73+
# fswatch outputs one line per event; we only need the first one
74+
fswatch \
75+
--recursive \
76+
--include='.*\.sh$' \
77+
--exclude='.*' \
78+
--one-event \
79+
"$path" 2>/dev/null
80+
;;
81+
esac
82+
}

tests/acceptance/snapshots/bashunit_path_test_sh.test_bashunit_without_path_env_nor_argument.snapshot

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Commands:
88
doc [filter] Display assertion documentation
99
init [dir] Initialize a new test directory
1010
learn Start interactive tutorial
11+
watch [path] Watch files and re-run tests on change
1112
upgrade Upgrade bashunit to latest version
1213

1314
Global Options:

tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_help.snapshot

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Commands:
77
doc [filter] Display assertion documentation
88
init [dir] Initialize a new test directory
99
learn Start interactive tutorial
10+
watch [path] Watch files and re-run tests on change
1011
upgrade Upgrade bashunit to latest version
1112

1213
Global Options:

tests/unit/watch_test.sh

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/usr/bin/env bash
2+
3+
# shellcheck disable=SC2329 # Test functions are invoked indirectly by bashunit
4+
############################
5+
# bashunit::watch::is_available
6+
############################
7+
8+
function test_is_available_returns_inotifywait_when_present() {
9+
bashunit::mock bashunit::watch::_command_exists mock_true
10+
11+
assert_equals "inotifywait" "$(bashunit::watch::is_available)"
12+
}
13+
14+
function test_is_available_returns_fswatch_when_inotifywait_missing() {
15+
local call_count=0
16+
function bashunit::watch::_command_exists() {
17+
call_count=$((call_count + 1))
18+
[[ $call_count -eq 2 ]]
19+
}
20+
21+
assert_equals "fswatch" "$(bashunit::watch::is_available)"
22+
}
23+
24+
function test_is_available_returns_empty_when_no_tool_found() {
25+
bashunit::mock bashunit::watch::_command_exists mock_false
26+
27+
assert_empty "$(bashunit::watch::is_available)"
28+
}
29+
30+
############################
31+
# bashunit::watch::run — error path (no tool)
32+
# run() calls exit 1, so we must capture it in a subshell
33+
############################
34+
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 ""
46+
47+
local output
48+
output=$(bashunit::watch::run "tests/" 2>&1) || true
49+
50+
assert_contains "inotifywait" "$output"
51+
assert_contains "fswatch" "$output"
52+
}
53+
54+
function test_run_error_message_includes_install_hints() {
55+
bashunit::mock bashunit::watch::is_available echo ""
56+
57+
local output
58+
output=$(bashunit::watch::run "tests/" 2>&1) || true
59+
60+
assert_contains "apt install inotify-tools" "$output"
61+
assert_contains "brew install fswatch" "$output"
62+
}
63+
64+
############################
65+
# bashunit::watch::wait_for_change — tool dispatch
66+
############################
67+
68+
function test_wait_for_change_calls_inotifywait_on_linux() {
69+
bashunit::spy inotifywait
70+
71+
bashunit::watch::wait_for_change "inotifywait" "tests/" 2>/dev/null || true
72+
73+
assert_have_been_called inotifywait
74+
}
75+
76+
function test_wait_for_change_calls_fswatch_on_macos() {
77+
bashunit::spy fswatch
78+
79+
bashunit::watch::wait_for_change "fswatch" "tests/" 2>/dev/null || true
80+
81+
assert_have_been_called fswatch
82+
}
83+
84+
function test_wait_for_change_does_nothing_for_unknown_tool() {
85+
bashunit::spy inotifywait
86+
bashunit::spy fswatch
87+
88+
bashunit::watch::wait_for_change "unknown-tool" "tests/" 2>/dev/null || true
89+
90+
assert_not_called inotifywait
91+
assert_not_called fswatch
92+
}

0 commit comments

Comments
 (0)