diff --git a/.agnostic-ai/AGNOSTIC_AI.md b/.agnostic-ai/AGNOSTIC_AI.md index 1c985231..20695bcf 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 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 6260e4c3..0d778925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,37 +9,41 @@ ### Fixed -- 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) -- Echo data providers keep `#` and parentheses, and report malformed rows without dropping later rows. Quoted values keep carriage returns on Windows (#1419) +#### Documentation + - Homepage logo follows reduced-motion preferences and stops tracking the pointer after navigation - Primary buttons use dark text in both themes, including hover and active states - Quickstart commands match install.sh, npm project and npm global installs -- 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) + +#### 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 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) -- TAP reports keep test titles containing line breaks on a single result line (#1413) +- Snapshots record and update literal `-n`, `-e`, `-E` and combined echo flag values correctly (#1402) +- 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 keep `#` and parentheses, and report malformed rows without dropping later rows. Quoted values keep carriage returns on Windows (#1419) +- 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) -- `assert_string_not_starts_with` treats variadic haystack arguments as one newline-joined value and keeps failures labeled with the test's name (#1403) ### Changed diff --git a/docs/assertions.md b/docs/assertions.md index 915af3aa..69bfbb04 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -527,7 +527,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 83a57bb7..1b188908 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/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 9c0a2f21..8f872cc2 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 @@ -216,7 +216,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 1540e821..e564560a 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). diff --git a/tests/unit/assert/dates_test.sh b/tests/unit/assert/dates_test.sh index fb498ab8..b7d97021 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")" }