From 58181b3f99adb941860a4f391031703daa3744e6 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 29 Oct 2026 23:10:15 +0100 Subject: [PATCH 1/6] fix(assert): join negative prefix haystacks Closes #1403 --- CHANGELOG.md | 2 ++ src/assert/core.sh | 8 ++++++-- tests/unit/assert/string_test.sh | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067c59326..d70eb2997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- `assert_string_not_starts_with` joins every haystack argument and keeps the test name as its failure label, like its siblings (#1403) + - `init` writes the bootstrap path and a custom tests directory to `.env` quoted, so a directory with a space runs afterwards instead of breaking every next run (#1411) - Failure source context stays inside a one-line test instead of listing the next function's assertions (#1420) - Parallel runs count tests under relative hidden paths and keep their results separate from matching visible paths (#1393) diff --git a/src/assert/core.sh b/src/assert/core.sh index 3f2c39519..be90e9a6e 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -903,15 +903,19 @@ function assert_string_starts_with() { } function assert_string_not_starts_with() { + local label_override="" bashunit::assert::should_skip && return 0 if [ "$#" -lt 2 ]; then bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected, actual" "$#" return 2 fi + local IFS=$' \t\n' local expected="$1" - local actual="$2" - local label_override="${3:-}" + local -a actual_arr + actual_arr=("${@:2}") + bashunit::assert::join_to_slot "${actual_arr[@]+"${actual_arr[@]}"}" + local actual=$_BASHUNIT_ASSERT_JOINED_OUT case "$actual" in "$expected"*) diff --git a/tests/unit/assert/string_test.sh b/tests/unit/assert/string_test.sh index 03f816af0..27d9ab26b 100644 --- a/tests/unit/assert/string_test.sh +++ b/tests/unit/assert/string_test.sh @@ -310,3 +310,19 @@ function test_assert_contains_ignore_case_folds_non_ascii() { assert_contains_ignore_case "ñü" "Test ÑÜ string" assert_contains_ignore_case "ÑÜ" "test ñü string" } + +function test_not_starts_with_joins_lines_and_keeps_the_test_label() { + assert_same \ + "$(bashunit::console_results::print_failed_test \ + 'Not starts with joins lines and keeps the test label' $'abc\nzzz' 'to not start with' a)" \ + "$(assert_string_not_starts_with a abc zzz)" +} + +function test_not_starts_with_compares_a_prefix_across_argument_boundaries() { + assert_same \ + "$(bashunit::console_results::print_failed_test \ + 'Not starts with compares a prefix across argument boundaries' \ + $'line1\nline2' 'to not start with' $'line1\nline2')" \ + "$(assert_string_not_starts_with $'line1\nline2' line1 line2)" + assert_empty "$(assert_string_not_starts_with x line1 line2)" +} From 783e2db32fe8e2a0d920152b3d59f2fc39c4a85e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 29 Oct 2026 23:10:30 +0100 Subject: [PATCH 2/6] fix(assert): validate BSD dates and colon offsets Closes #1404 --- CHANGELOG.md | 2 ++ docs/assertions.md | 2 +- src/assert/dates.sh | 53 +++++++++++++++++---------------- tests/unit/assert/dates_test.sh | 38 +++++++++++++++++++++++ 4 files changed, 68 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067c59326..7661f4c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Date assertions reject trailing text on macOS and parse colon-separated timezone offsets consistently across platforms (#1404) + - `init` writes the bootstrap path and a custom tests directory to `.env` quoted, so a directory with a space runs afterwards instead of breaking every next run (#1411) - Failure source context stays inside a one-line test instead of listing the next function's assertions (#1420) - Parallel runs count tests under relative hidden paths and keep their results separate from matching visible paths (#1393) diff --git a/docs/assertions.md b/docs/assertions.md index f0c056a85..c54713e7f 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -525,7 +525,7 @@ Inputs are automatically converted to epoch seconds. Supported formats: - ISO 8601 date: `2023-11-14` - ISO 8601 datetime: `2023-11-14T12:00:00` - ISO 8601 datetime with UTC Z: `2023-11-14T12:00:00Z` -- ISO 8601 datetime with timezone offset: `2023-11-14T12:00:00+0100` +- ISO 8601 datetime with timezone offset: `2023-11-14T12:00:00+0100` or `2023-11-14T12:00:00+01:00` - Space-separated datetime: `2023-11-14 12:00:00` You can mix formats in the same assertion (e.g., one epoch, one ISO). diff --git a/src/assert/dates.sh b/src/assert/dates.sh index 83a57bb7a..1b1889087 100644 --- a/src/assert/dates.sh +++ b/src/assert/dates.sh @@ -1,15 +1,28 @@ #!/usr/bin/env bash +function bashunit::date::_bsd_to_epoch() { + local input="$1" format + local date_pattern='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + local time_pattern='[0-9][0-9]:[0-9][0-9]:[0-9][0-9]' + # shellcheck disable=SC2254 # Date-shape glob patterns must expand unquoted. + case "$input" in + $date_pattern"T"${time_pattern}[+-][0-9][0-9][0-9][0-9]) format="%Y-%m-%dT%H:%M:%S%z" ;; + $date_pattern"T"$time_pattern) format="%Y-%m-%dT%H:%M:%S" ;; + $date_pattern" "$time_pattern) format="%Y-%m-%d %H:%M:%S" ;; + $date_pattern) + input="$input 00:00:00" + format="%Y-%m-%d %H:%M:%S" + ;; + *) return 1 ;; + esac + # BSD date accepts an unparsed suffix, so validate the complete shape first. + date -j -f "$format" "$input" +%s 2>/dev/null +} + function bashunit::date::to_epoch() { local input="$1" - # An empty string is never a date, and it has to be rejected here rather than - # left to the cascade below, because the two date(1) implementations disagree - # about it: GNU reads `date -d ""` as "now" and exits 0, while on BSD/macOS - # -d is not even a valid option. Falling through therefore resolved two empty - # inputs to the same epoch and compared them equal on Linux while correctly - # failing on macOS. It also matched the all-digits fast path below vacuously - # (an empty string contains no non-digit characters). + # GNU date treats an empty input as now; date assertions must reject it. if [ -z "$input" ]; then echo "$input" return 1 @@ -24,6 +37,10 @@ function bashunit::date::to_epoch() { ;; esac + case "$input" in + *[+-][0-9][0-9]:[0-9][0-9]) input="${input%:??}${input##*:}" ;; + esac + # Handle Z (UTC) suffix explicitly: BusyBox needs TZ=UTC, BSD needs +0000 case "$input" in *Z) @@ -34,7 +51,7 @@ function bashunit::date::to_epoch() { epoch=$(TZ=UTC date -d "$utc_input" +%s 2>/dev/null) && { echo "$epoch"; return 0; } epoch=$(TZ=UTC date -d "$utc_norm" +%s 2>/dev/null) && { echo "$epoch"; return 0; } # BSD: use +0000 offset which %z understands - epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "${utc_input}+0000" +%s 2>/dev/null) && { echo "$epoch"; return 0; } + epoch=$(bashunit::date::_bsd_to_epoch "${utc_input}+0000") && { echo "$epoch"; return 0; } echo "$input" return 1 ;; @@ -82,29 +99,13 @@ function bashunit::date::to_epoch() { return 0 } fi - # Try BSD date (-j -f flag) with ISO 8601 datetime + timezone offset - epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$input" +%s 2>/dev/null) && { - echo "$epoch" - return 0 - } - # Try BSD date with ISO 8601 datetime format - epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$input" +%s 2>/dev/null) && { - echo "$epoch" - return 0 - } - # Try BSD date with space-separated datetime format - epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$input" +%s 2>/dev/null) && { - echo "$epoch" - return 0 - } - # Try BSD date with date-only format (append midnight for deterministic results) - epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$input 00:00:00" +%s 2>/dev/null) && { + epoch=$(bashunit::date::_bsd_to_epoch "$input") && { echo "$epoch" return 0 } # Unsupported format - echo "$input" + echo "$1" return 1 } diff --git a/tests/unit/assert/dates_test.sh b/tests/unit/assert/dates_test.sh index fb498ab81..b7d97021a 100644 --- a/tests/unit/assert/dates_test.sh +++ b/tests/unit/assert/dates_test.sh @@ -14,6 +14,44 @@ function test_unsuccessful_assert_date_equals() { "$(assert_date_equals "1700000000" "1600000000")" } +function test_unsuccessful_assert_date_equals_with_trailing_text() { + assert_same\ + "$(bashunit::console_results::print_failed_test\ + "Unsuccessful assert date equals with trailing text"\ + "2024-01-01T10:00:00 nonsense" "to be" "a valid date")"\ + "$(assert_date_equals "2024-01-01T10:00:00 nonsense" "2024-01-01T10:00:00")" +} + +function test_successful_assert_date_equals_with_colon_offset() { + assert_empty "$(assert_date_equals "2024-01-01T10:00:00+0200" "2024-01-01T10:00:00+02:00")" + assert_empty "$(assert_date_equals "1704096000" "2024-01-01T10:00:00+02:00")" +} + +function test_successful_assert_date_equals_with_negative_colon_offset() { + assert_empty "$(assert_date_equals "2024-01-01T10:00:00-0430" "2024-01-01T10:00:00-04:30")" + assert_empty "$(assert_date_equals "1704119400" "2024-01-01T10:00:00-04:30")" +} + +function test_date_parser_rejects_trailing_text_in_each_supported_shape() { + local input actual code + for input in \ + '2024-01-01 nonsense' \ + '2024-01-01 10:00:00 nonsense' \ + '2024-01-01T10:00:00+0200 nonsense' \ + '2024-01-01T10:00:00+02:00 nonsense' \ + '2024-01-01T10:00:00 nonsenseZ'; do + code=0 + actual=$(bashunit::date::to_epoch "$input") || code=$? + assert_same 1 "$code" + assert_same "$input" "$actual" + done +} + +function test_successful_assert_date_equals_preserves_explicit_timezone_epochs() { + assert_empty "$(assert_date_equals "1704096000" "2024-01-01T10:00:00+0200")" + assert_empty "$(assert_date_equals "1704103200" "2024-01-01T10:00:00Z")" +} + function test_successful_assert_date_before() { assert_empty "$(assert_date_before "1700000000" "1600000000")" } From 676bac0d65b62e6551447d75fe44211895383ad3 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 29 Oct 2026 23:25:00 +0100 Subject: [PATCH 3/6] test(dates): update offset documentation snapshots --- ...est_sh.test_bashunit_should_display_all_assert_docs.snapshot | 2 +- ...h.test_bashunit_should_display_filtered_assert_docs.snapshot | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index 6504b02f7..6f6f84c92 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -214,7 +214,7 @@ Inputs are automatically converted to epoch seconds. Supported formats: - ISO 8601 date: `2023-11-14` - ISO 8601 datetime: `2023-11-14T12:00:00` - ISO 8601 datetime with UTC Z: `2023-11-14T12:00:00Z` -- ISO 8601 datetime with timezone offset: `2023-11-14T12:00:00+0100` +- ISO 8601 datetime with timezone offset: `2023-11-14T12:00:00+0100` or `2023-11-14T12:00:00+01:00` - Space-separated datetime: `2023-11-14 12:00:00` You can mix formats in the same assertion (e.g., one epoch, one ISO). diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_filtered_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_filtered_assert_docs.snapshot index c98b2c1da..5df6f5e3e 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_filtered_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_filtered_assert_docs.snapshot @@ -22,7 +22,7 @@ Inputs are automatically converted to epoch seconds. Supported formats: - ISO 8601 date: `2023-11-14` - ISO 8601 datetime: `2023-11-14T12:00:00` - ISO 8601 datetime with UTC Z: `2023-11-14T12:00:00Z` -- ISO 8601 datetime with timezone offset: `2023-11-14T12:00:00+0100` +- ISO 8601 datetime with timezone offset: `2023-11-14T12:00:00+0100` or `2023-11-14T12:00:00+01:00` - Space-separated datetime: `2023-11-14 12:00:00` You can mix formats in the same assertion (e.g., one epoch, one ISO). From c57eae80c7b302a38a38ad154ed50c6a51fa5b2b Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 30 Oct 2026 02:19:18 +0100 Subject: [PATCH 4/6] docs: group unreleased changelog notes --- CHANGELOG.md | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a21f68ea8..b671901c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,35 +8,35 @@ ### Fixed +#### Assertions and doubles + - Date assertions reject trailing text on macOS and parse colon-separated timezone offsets consistently across platforms (#1404) -- `assert_string_not_starts_with` joins every haystack argument and keeps the test name as its failure label, like its siblings (#1403) +- `assert_string_not_starts_with` joins every haystack argument and uses the test name as its failure label, matching its siblings (#1403). `assert_matches` and `assert_not_matches` accept patterns starting with `-` and fail when grep rejects a pattern (#1398) +- `assert_files_equals` and `assert_files_not_equals` fail and name the missing path when either file does not exist (#1397) - Snapshots record and update literal `-n`, `-e`, `-E` and combined echo flag values correctly (#1402) -- Generated workflows run test directories containing whitespace as a single path without requiring a committed `.env` (#1425) -- Annotations, tags, header counts and duplicate checks support functions declared without parentheses (#1422) +- Spies keep multiline arguments in one recorded call, including comparisons and failure logs (#1399). Call argument assertions fail when the spy was never called or the requested call index does not exist (#1400) +- `bashunit::unmock` restores shell functions replaced by spies, mocks or mock sequences (#1401) + +#### Setup and selection + +- `install.sh` and `upgrade` keep the installed binary when a download fails or is empty; installation also keeps it when checksum verification fails (#1406, #1407) +- `install.sh` resolves relative destinations from the caller's directory, reports the resolved path (#1408), and rejects prefixed or incomplete version numbers instead of treating them as directories (#1409) +- `init` quotes the bootstrap path and custom tests directory in `.env`, so paths containing spaces work on later runs (#1411). Generated workflows pass a tests directory containing whitespace as one path without requiring a committed `.env` (#1425) +- `.bashunitrc` suite options stay out of the environment; global settings before and after suite sections still apply (#1410) +- Include, inline and exclusion filters preserve `test_` inside names such as `latest_version` (#1414). `--debug` keeps an existing test file or directory as the test path instead of taking it as the trace file (#1405) + +#### Test discovery and execution + +- Annotations, tags, header counts and duplicate checks support functions declared without parentheses (#1422). Duplicate definitions with an opening brace on the next line are reported instead of replacing the first definition (#1421) - Echo data providers preserve words containing `#` or parentheses and report malformed rows without dropping later rows (#1419) -- A green file or filtered subset preserves rerun-cache entries for tests it did not execute (#1418) -- SIGTERM and SIGHUP run teardown hooks, stop parallel workers, and remove the run's scratch files before exiting with status 1 (#1417) -- Sequential stop-on-failure runs write requested reports and the rerun cache for the tests that ran (#1415) -- Include, inline, and exclusion filters preserve `test_` inside names such as `latest_version` (#1414) -- `.bashunitrc` suite options stay out of the environment, while global settings before and after suite sections still apply (#1410) -- `install.sh` rejects prefixed or incomplete version numbers instead of treating them as directories (#1409) -- `install.sh` resolves relative destinations from the caller's directory and reports the resolved installation path (#1408) -- `bashunit::unmock` restores a shell function replaced by a spy, mock, or mock sequence (#1401) -- Spies keep a multiline argument in one recorded call, including call comparisons and failure logs (#1399) -- Parallel results and hook output stay separate for test paths that differ by slashes and underscores (#1416) -- Coverage report paths are checked before tests run, and LCOV or HTML write failures make the run fail instead of reporting success (#1412) -- `upgrade` keeps the installed binary when a download fails or returns an empty file (#1407) -- `install.sh` keeps the installed binary when a download fails, is empty, or fails checksum verification (#1406) -- `--debug` leaves an existing test file or directory as the test path instead of taking it as the trace file (#1405) -- Call argument assertions fail when the spy was never called or the requested call index does not exist (#1400) -- `assert_matches` and `assert_not_matches` accept patterns starting with `-` and fail when grep rejects a pattern (#1398) -- `assert_files_equals` and `assert_files_not_equals` fail and name the missing path when either file does not exist (#1397) -- TAP reports keep test titles containing line breaks on a single result line (#1413) +- SIGTERM and SIGHUP run teardown hooks, stop parallel workers and remove scratch files before exiting with status 1 (#1417) +- Parallel results and hook output stay separate for paths that differ by slashes and underscores (#1416). Relative hidden paths are counted and keep separate results from matching visible paths (#1393) + +#### Reports and reruns -- `init` writes the bootstrap path and a custom tests directory to `.env` quoted, so a directory with a space runs afterwards instead of breaking every next run (#1411) +- Sequential stop-on-failure runs write requested reports and the rerun cache for tests that ran (#1415). A green file or filtered subset preserves cache entries for tests it did not execute (#1418) +- Coverage report paths are checked before tests run; LCOV or HTML write failures make the run fail (#1412). TAP reports keep test titles containing line breaks on one result line (#1413) - Failure source context stays inside a one-line test instead of listing the next function's assertions (#1420) -- Parallel runs count tests under relative hidden paths and keep their results separate from matching visible paths (#1393) -- Duplicate test functions are detected when the opening brace is on the next line instead of silently replacing the first definition (#1421) ### Changed From 6f3db431d79149929536ad6f2a82f1a4eb3dffc2 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 30 Oct 2026 03:01:55 +0100 Subject: [PATCH 5/6] chore: drop superseded prefix assertion changes --- CHANGELOG.md | 1 - src/assert/core.sh | 1 - tests/unit/assert/string_test.sh | 16 ---------------- 3 files changed, 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 783670c8e..62430297b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ ### Fixed - Date assertions reject trailing text on macOS and parse colon-separated timezone offsets consistently across platforms (#1404) -- `assert_string_not_starts_with` joins every haystack argument and keeps the test name as its failure label, like its siblings (#1403) - Snapshots record and update literal `-n`, `-e`, `-E` and combined echo flag values correctly (#1402) - Generated workflows run test directories containing whitespace as a single path without requiring a committed `.env` (#1425) - Annotations, tags, header counts and duplicate checks support functions declared without parentheses (#1422) diff --git a/src/assert/core.sh b/src/assert/core.sh index b0e3761d6..49c524ebf 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -921,7 +921,6 @@ function assert_string_not_starts_with() { fi local IFS=$' \t\n' - local IFS=$' \t\n' local expected="$1" local -a actual_arr actual_arr=("${@:2}") diff --git a/tests/unit/assert/string_test.sh b/tests/unit/assert/string_test.sh index ce95dd96d..28a82c82d 100644 --- a/tests/unit/assert/string_test.sh +++ b/tests/unit/assert/string_test.sh @@ -373,19 +373,3 @@ function test_assert_contains_ignore_case_folds_non_ascii() { assert_contains_ignore_case "ñü" "Test ÑÜ string" assert_contains_ignore_case "ÑÜ" "test ñü string" } - -function test_not_starts_with_joins_lines_and_keeps_the_test_label() { - assert_same \ - "$(bashunit::console_results::print_failed_test \ - 'Not starts with joins lines and keeps the test label' $'abc\nzzz' 'to not start with' a)" \ - "$(assert_string_not_starts_with a abc zzz)" -} - -function test_not_starts_with_compares_a_prefix_across_argument_boundaries() { - assert_same \ - "$(bashunit::console_results::print_failed_test \ - 'Not starts with compares a prefix across argument boundaries' \ - $'line1\nline2' 'to not start with' $'line1\nline2')" \ - "$(assert_string_not_starts_with $'line1\nline2' line1 line2)" - assert_empty "$(assert_string_not_starts_with x line1 line2)" -} From 39dc7223f1300dab3c3d29b6f9fb193ed49d4e15 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 30 Oct 2026 07:32:28 +0100 Subject: [PATCH 6/6] docs: bound validation workers --- .agnostic-ai/AGNOSTIC_AI.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agnostic-ai/AGNOSTIC_AI.md b/.agnostic-ai/AGNOSTIC_AI.md index 1c9852311..20695bcf4 100644 --- a/.agnostic-ai/AGNOSTIC_AI.md +++ b/.agnostic-ai/AGNOSTIC_AI.md @@ -13,8 +13,8 @@ Preserve Bash 3.0 parsing and public APIs; follow sibling tests/code. Update doc Main selects checks by changed behavior/risk and reuses unchanged passes. Small fixes: affected tests, ShellCheck, `make lint`; strict mode for options, errors, hooks, or workers. Cross-cutting runtime changes/releases need: ```bash -make test -make test/strict +BASHUNIT_PARALLEL_JOBS=4 make test +BASHUNIT_PARALLEL_JOBS=4 make test/strict make sa make lint ```