Skip to content

Commit 9d176b5

Browse files
authored
Merge branch 'main' into perf/p0-performance-optimizations
2 parents 8467c86 + d1c5a84 commit 9d176b5

11 files changed

Lines changed: 359 additions & 21 deletions

CHANGELOG.md

Lines changed: 14 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
@@ -20,6 +25,15 @@
2025
- Reduce subshells in test execution hot path
2126
- Batch coverage recording with in-memory buffering
2227

28+
### Fixed
29+
- JUnit XML report now conforms to the standard schema
30+
- Remove non-standard `passed`, `incomplete`, `snapshot` attributes from `<testsuite>` and `status`, `assertions` from `<testcase>`
31+
- Add `errors="0"` attribute and `<failure>`/`<skipped>` child elements per the JUnit spec
32+
- `skipped` count now includes both skipped and incomplete tests to match emitted `<skipped/>` elements
33+
- Convert `time` values from milliseconds to seconds (float) as expected by CI tools
34+
- Strip ANSI escape sequences and invalid XML control characters from failure messages
35+
- Include actual failure messages in `<failure>` body instead of hard-coded placeholders
36+
2337
## [0.33.0](https://github.com/TypedDevs/bashunit/compare/0.32.0...0.33.0) - 2026-02-15
2438

2539
### Changed

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/reports.sh

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ _BASHUNIT_REPORTS_TEST_NAMES=()
66
_BASHUNIT_REPORTS_TEST_STATUSES=()
77
_BASHUNIT_REPORTS_TEST_DURATIONS=()
88
_BASHUNIT_REPORTS_TEST_ASSERTIONS=()
9+
_BASHUNIT_REPORTS_TEST_FAILURES=()
910

1011
function bashunit::reports::add_test_snapshot() {
1112
bashunit::reports::add_test "$1" "$2" "$3" "$4" "snapshot"
@@ -24,7 +25,7 @@ function bashunit::reports::add_test_passed() {
2425
}
2526

2627
function bashunit::reports::add_test_failed() {
27-
bashunit::reports::add_test "$1" "$2" "$3" "$4" "failed"
28+
bashunit::reports::add_test "$1" "$2" "$3" "$4" "failed" "$5"
2829
}
2930

3031
function bashunit::reports::add_test() {
@@ -36,45 +37,69 @@ function bashunit::reports::add_test() {
3637
local duration="$3"
3738
local assertions="$4"
3839
local status="$5"
40+
local failure_message="${6:-}"
3941

4042
_BASHUNIT_REPORTS_TEST_FILES[${#_BASHUNIT_REPORTS_TEST_FILES[@]}]="$file"
4143
_BASHUNIT_REPORTS_TEST_NAMES[${#_BASHUNIT_REPORTS_TEST_NAMES[@]}]="$test_name"
4244
_BASHUNIT_REPORTS_TEST_STATUSES[${#_BASHUNIT_REPORTS_TEST_STATUSES[@]}]="$status"
4345
_BASHUNIT_REPORTS_TEST_ASSERTIONS[${#_BASHUNIT_REPORTS_TEST_ASSERTIONS[@]}]="$assertions"
4446
_BASHUNIT_REPORTS_TEST_DURATIONS[${#_BASHUNIT_REPORTS_TEST_DURATIONS[@]}]="$duration"
47+
_BASHUNIT_REPORTS_TEST_FAILURES[${#_BASHUNIT_REPORTS_TEST_FAILURES[@]}]="$failure_message"
48+
}
49+
50+
function bashunit::reports::__xml_escape() {
51+
local text="$1"
52+
# Strip ANSI escape sequences and control characters invalid in XML 1.0,
53+
# then escape XML special characters (& first to avoid double-escaping)
54+
echo "$text" \
55+
| sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
56+
| tr -d '\000-\010\013\014\016-\037' \
57+
| sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g' -e "s/'/\&apos;/g"
4558
}
4659

4760
function bashunit::reports::generate_junit_xml() {
4861
local output_file="$1"
4962

50-
local test_passed=$(bashunit::state::get_tests_passed)
5163
local tests_skipped=$(bashunit::state::get_tests_skipped)
5264
local tests_incomplete=$(bashunit::state::get_tests_incomplete)
53-
local tests_snapshot=$(bashunit::state::get_tests_snapshot)
5465
local tests_failed=$(bashunit::state::get_tests_failed)
55-
local time=$(bashunit::clock::total_runtime_in_milliseconds)
66+
local time_ms=$(bashunit::clock::total_runtime_in_milliseconds)
67+
local time
68+
time=$(LC_ALL=C awk -v ms="$time_ms" 'BEGIN {printf "%.3f", ms/1000}')
5669

5770
{
5871
echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
5972
echo "<testsuites>"
6073
echo " <testsuite name=\"bashunit\" tests=\"${#_BASHUNIT_REPORTS_TEST_NAMES[@]}\""
61-
echo " passed=\"$test_passed\" failures=\"$tests_failed\" incomplete=\"$tests_incomplete\""
62-
echo " skipped=\"$tests_skipped\" snapshot=\"$tests_snapshot\""
74+
echo " failures=\"$tests_failed\" errors=\"0\""
75+
echo " skipped=\"$(( tests_skipped + tests_incomplete ))\""
6376
echo " time=\"$time\">"
6477

6578
local i
6679
for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
6780
local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}"
6881
local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
69-
local assertions="${_BASHUNIT_REPORTS_TEST_ASSERTIONS[$i]:-}"
7082
local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
71-
local test_time="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}"
83+
local test_time_ms="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}"
84+
local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}"
85+
local test_time
86+
test_time=$(LC_ALL=C awk -v ms="$test_time_ms" 'BEGIN {printf "%.3f", ms/1000}')
7287

7388
echo " <testcase file=\"$file\""
7489
echo " name=\"$name\""
75-
echo " status=\"$status\""
76-
echo " assertions=\"$assertions\""
7790
echo " time=\"$test_time\">"
91+
92+
# Add failure element for failed tests with actual failure message
93+
if [[ "$status" == "failed" ]]; then
94+
local escaped_message
95+
escaped_message=$(bashunit::reports::__xml_escape "$failure_message")
96+
echo " <failure message=\"Test failed\">$escaped_message</failure>"
97+
elif [[ "$status" == "skipped" ]]; then
98+
echo " <skipped/>"
99+
elif [[ "$status" == "incomplete" ]]; then
100+
echo " <skipped message=\"Test incomplete\"/>"
101+
fi
102+
78103
echo " </testcase>"
79104
done
80105

src/runner.sh

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -735,15 +735,15 @@ function bashunit::runner::run_test() {
735735
error_message="$hook_message"
736736
fi
737737
bashunit::console_results::print_error_test "$failure_function" "$error_message" "$runtime_output"
738-
bashunit::reports::add_test_failed "$test_file" "$failure_label" "$duration" "$total_assertions"
738+
bashunit::reports::add_test_failed "$test_file" "$failure_label" "$duration" "$total_assertions" "$error_message"
739739
bashunit::runner::write_failure_result_output "$test_file" "$failure_function" "$error_message" "$runtime_output"
740740
bashunit::internal_log "Test error" "$failure_label" "$error_message"
741741
return
742742
fi
743743

744744
if [[ "$current_assertions_failed" != "$(bashunit::state::get_assertions_failed)" ]]; then
745745
bashunit::state::add_tests_failed
746-
bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions"
746+
bashunit::reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" "$subshell_output"
747747
bashunit::runner::write_failure_result_output "$test_file" "$fn_name" "$subshell_output"
748748

749749
bashunit::internal_log "Test failed" "$label"
@@ -998,7 +998,9 @@ function bashunit::runner::record_file_hook_failure() {
998998

999999
bashunit::state::add_tests_failed
10001000
bashunit::console_results::print_error_test "$hook_name" "$hook_output"
1001-
bashunit::reports::add_test_failed "$test_file" "$(bashunit::helper::normalize_test_function_name "$hook_name")" 0 0
1001+
local _normalized_hook
1002+
_normalized_hook="$(bashunit::helper::normalize_test_function_name "$hook_name")"
1003+
bashunit::reports::add_test_failed "$test_file" "$_normalized_hook" 0 0 "$hook_output"
10021004
bashunit::runner::write_failure_result_output "$test_file" "$hook_name" "$hook_output"
10031005

10041006
return "$status"

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:

0 commit comments

Comments
 (0)