Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
## Unreleased

### Added
- Add `watch [path]` subcommand to re-run tests automatically on file changes
- Uses `inotifywait` on Linux (via `inotify-tools`) or `fswatch` on macOS
- Falls back with a clear install hint if neither tool is available
- Accepts optional path argument (defaults to current directory)

- Add date comparison assertions: `assert_date_equals`, `assert_date_before`, `assert_date_after`, `assert_date_within_range`, `assert_date_within_delta`
- Auto-detects epoch seconds, ISO 8601, space-separated datetime, and timezone offsets
- Mixed formats supported in the same assertion call
Expand Down
4 changes: 3 additions & 1 deletion bashunit
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ source "$BASHUNIT_ROOT_DIR/src/console_results.sh"
source "$BASHUNIT_ROOT_DIR/src/helpers.sh"
source "$BASHUNIT_ROOT_DIR/src/test_title.sh"
source "$BASHUNIT_ROOT_DIR/src/upgrade.sh"
source "$BASHUNIT_ROOT_DIR/src/watch.sh"
source "$BASHUNIT_ROOT_DIR/src/assertions.sh"
source "$BASHUNIT_ROOT_DIR/src/doc.sh"
source "$BASHUNIT_ROOT_DIR/src/reports.sh"
Expand All @@ -88,7 +89,7 @@ bashunit::clock::init
_SUBCOMMAND=""

case "${1:-}" in
test | bench | doc | init | learn | upgrade | assert)
test | bench | doc | init | learn | upgrade | assert | watch)
_SUBCOMMAND="$1"
shift
;;
Expand Down Expand Up @@ -123,4 +124,5 @@ case "$_SUBCOMMAND" in
learn) bashunit::main::cmd_learn "$@" ;;
upgrade) bashunit::main::cmd_upgrade "$@" ;;
assert) bashunit::main::cmd_assert "$@" ;;
watch) bashunit::main::cmd_watch "$@" ;;
esac
26 changes: 26 additions & 0 deletions src/console_header.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ Commands:
doc [filter] Display assertion documentation
init [dir] Initialize a new test directory
learn Start interactive tutorial
watch [path] Watch files and re-run tests on change
upgrade Upgrade bashunit to latest version

Global Options:
Expand Down Expand Up @@ -271,3 +272,28 @@ Note: You can also use 'bashunit test --assert <fn> <args>' (deprecated).
More info: https://bashunit.typeddevs.com/standalone
EOF
}

function bashunit::console_header::print_watch_help() {
cat << 'ENDOFHELP'
Usage: bashunit watch [path] [test-options]

Watch .sh files for changes and automatically re-run tests.

Arguments:
[path] Directory or file to watch and test (default: .)

Options:
-h, --help Show this help message
Any option accepted by 'bashunit test' is also accepted here.

Requirements:
Linux: inotifywait (sudo apt install inotify-tools)
macOS: fswatch (brew install fswatch)

Examples:
bashunit watch Watch current directory
bashunit watch tests/ Watch the tests/ directory
bashunit watch tests/ --filter user Watch and filter by name
bashunit watch tests/ --simple Watch with simple output
ENDOFHELP
}
16 changes: 16 additions & 0 deletions src/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,22 @@ function bashunit::main::cmd_learn() {
exit 0
}

#############################
# Subcommand: watch
#############################
function bashunit::main::cmd_watch() {
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
bashunit::console_header::print_watch_help
exit 0
fi

local path="${1:-.}"
shift || true
local -a extra_args=("$@")

bashunit::watch::run "$path" "${extra_args[@]+\"${extra_args[@]}\"}"
}

#############################
# Subcommand: upgrade
#############################
Expand Down
82 changes: 82 additions & 0 deletions src/watch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash

# bashunit watch mode
# Watches test and source files for changes and re-runs tests automatically.
# Requires: inotifywait (inotify-tools) on Linux, or fswatch on macOS.

function bashunit::watch::_command_exists() {
command -v "$1" &>/dev/null
}

function bashunit::watch::is_available() {
if bashunit::watch::_command_exists inotifywait; then
echo "inotifywait"
elif bashunit::watch::_command_exists fswatch; then
echo "fswatch"
else
echo ""
fi
}

function bashunit::watch::run() {
local path="${1:-.}"
shift
local extra_args=("$@")

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
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[@]}"}"

while true; do
bashunit::watch::wait_for_change "$tool" "$path"
printf "\n%s[change detected — re-running tests]%s\n\n" \
"${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}"
bashunit::watch::run_tests "$path" "${extra_args[@]+"${extra_args[@]}"}"
done
}

function bashunit::watch::run_tests() {
local path="$1"
shift
# Re-invoke bashunit test in a subshell so state resets cleanly each run
"$BASHUNIT_ROOT_DIR/bashunit" test "$path" "$@"
return $?
}

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

case "$tool" in
inotifywait)
inotifywait \
--quiet \
--recursive \
--event modify,create,delete,move \
--include '.*\.sh$' \
"$path" 2>/dev/null
;;
fswatch)
# fswatch outputs one line per event; we only need the first one
fswatch \
--recursive \
--include='.*\.sh$' \
--exclude='.*' \
--one-event \
"$path" 2>/dev/null
;;
esac
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Commands:
doc [filter] Display assertion documentation
init [dir] Initialize a new test directory
learn Start interactive tutorial
watch [path] Watch files and re-run tests on change
upgrade Upgrade bashunit to latest version

Global Options:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Commands:
doc [filter] Display assertion documentation
init [dir] Initialize a new test directory
learn Start interactive tutorial
watch [path] Watch files and re-run tests on change
upgrade Upgrade bashunit to latest version

Global Options:
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/watch_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env bash

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

function test_is_available_returns_inotifywait_when_present() {
bashunit::mock bashunit::watch::_command_exists mock_true

assert_equals "inotifywait" "$(bashunit::watch::is_available)"
}

function test_is_available_returns_fswatch_when_inotifywait_missing() {
local call_count=0
function bashunit::watch::_command_exists() {
call_count=$((call_count + 1))
[[ $call_count -eq 2 ]]
}

assert_equals "fswatch" "$(bashunit::watch::is_available)"
}

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

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

############################
# bashunit::watch::run — error path (no tool)
# run() calls exit 1, so we must capture it in a subshell
############################

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 ""

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

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

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

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

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

############################
# bashunit::watch::wait_for_change — tool dispatch
############################

function test_wait_for_change_calls_inotifywait_on_linux() {
bashunit::spy inotifywait

bashunit::watch::wait_for_change "inotifywait" "tests/" 2>/dev/null || true

assert_have_been_called inotifywait
}

function test_wait_for_change_calls_fswatch_on_macos() {
bashunit::spy fswatch

bashunit::watch::wait_for_change "fswatch" "tests/" 2>/dev/null || true

assert_have_been_called fswatch
}

function test_wait_for_change_does_nothing_for_unknown_tool() {
bashunit::spy inotifywait
bashunit::spy fswatch

bashunit::watch::wait_for_change "unknown-tool" "tests/" 2>/dev/null || true

assert_not_called inotifywait
assert_not_called fswatch
}
Loading