From 869b9b2341cb3f1beb2c03aebb765f08c1e128c5 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 1 Aug 2026 14:34:44 +0200 Subject: [PATCH] refactor(state): split src/state.sh into a src/state/ module Seventh module under ADR-010. 404 lines and 42 functions covering five concerns: state/counters.sh the _BASHUNIT_TESTS_*/_ASSERTIONS_* tallies and their 22 get/add accessors state/duplicates.sh duplicate test-function detection state state/context.sh per-test output buffer, exit code, title, interpolated name, hook failure/message, and initialize_assertions_count state/payload.sh encode_field, export_subshell_context, and the base64 -w capability probe those depend on state/parallel.sh aggregate_parallel_results counters.sh and duplicates.sh are sourced before context.sh, which owns the per-test reset that clears their globals. tests/unit/state_test.sh carries the layering contract from #868/#862 -- state must never call console_results, console_header, parallel or runner -- and it enforced it by grepping `src/state.sh`. With that file gone the grep matched nothing and the test passed vacuously, silently retiring the contract. It now globs `src/state/*.sh`, and the comment says why a single-file target is the wrong shape for this check. Verified by appending a call to bashunit::parallel::stop: red with it, green without. A relocation: the non-blank line multiset lost nothing, 42 functions before and after, and the built artifact's code content is identical. Related #931 --- .claude/rules/architecture-map.md | 7 +- bashunit | 2 +- src/state.sh | 404 ------------------------------ src/state/context.sh | 77 ++++++ src/state/counters.sh | 131 ++++++++++ src/state/duplicates.sh | 44 ++++ src/state/index.sh | 14 ++ src/state/parallel.sh | 145 +++++++++++ src/state/payload.sh | 66 +++++ tests/unit/state_test.sh | 12 +- 10 files changed, 491 insertions(+), 411 deletions(-) delete mode 100644 src/state.sh create mode 100644 src/state/context.sh create mode 100644 src/state/counters.sh create mode 100644 src/state/duplicates.sh create mode 100644 src/state/index.sh create mode 100644 src/state/parallel.sh create mode 100644 src/state/payload.sh diff --git a/.claude/rules/architecture-map.md b/.claude/rules/architecture-map.md index a05e3c54..a360ba72 100644 --- a/.claude/rules/architecture-map.md +++ b/.claude/rules/architecture-map.md @@ -64,7 +64,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end). | `helper/discovery.sh` | `find_files_recursive`, `find_total_tests`, fn filtering, duplicate check, line lookup | | `helper/provider.sh` / `helper/tags.sh` | `@data_provider` map; `@tag` extraction and matching | | `helper/encoding.sh` / `helper/functions.sh` / `helper/git.sh` | base64 + ids; generic fn utilities; remote tag lookup for `upgrade` | -| `state.sh` | counters, per-test payload encode/decode, TAP conversion | +| `state/index.sh` | aggregator only — sources the `src/state/` module below | +| `state/counters.sh` | test and assertion tallies and their get/add accessors | +| `state/duplicates.sh` | duplicate test-function detection state | +| `state/context.sh` | per-test output buffer, exit code, title, hook failure, and the per-test reset | +| `state/payload.sh` | the encoded per-test result payload and the base64 capability probe | +| `state/parallel.sh` | aggregating per-test result files after a `--parallel` run | | `env.sh` | all `BASHUNIT_*` defaults/config files, scratch dirs (`_BASHUNIT_RUN_OUTPUT_DIR` + EXIT-trap cleanup) | | `parallel.sh` | worker temp tree, aggregation, stop-on-failure flag file | | `console/index.sh` | aggregator only — sources the `src/console/` module below | diff --git a/bashunit b/bashunit index 6c139f12..029f1934 100755 --- a/bashunit +++ b/bashunit @@ -75,7 +75,7 @@ source "$BASHUNIT_ROOT_DIR/src/parallel.sh" source "$BASHUNIT_ROOT_DIR/src/env.sh" source "$BASHUNIT_ROOT_DIR/src/coverage/index.sh" source "$BASHUNIT_ROOT_DIR/src/clock.sh" -source "$BASHUNIT_ROOT_DIR/src/state.sh" +source "$BASHUNIT_ROOT_DIR/src/state/index.sh" source "$BASHUNIT_ROOT_DIR/src/console/index.sh" source "$BASHUNIT_ROOT_DIR/src/helper/index.sh" source "$BASHUNIT_ROOT_DIR/src/test_title.sh" diff --git a/src/state.sh b/src/state.sh deleted file mode 100644 index 0296275f..00000000 --- a/src/state.sh +++ /dev/null @@ -1,404 +0,0 @@ -#!/usr/bin/env bash - -# Cache base64 -w flag support (Alpine needs -w 0, macOS does not support -w). -# Scrape `base64 --help` once and match with a shell `case` instead of piping -# into a `grep` fork — same detection, one fewer fork per cold start. -_bashunit_base64_help="$(base64 --help 2>&1 || true)" -case "$_bashunit_base64_help" in -*-w*) _BASHUNIT_BASE64_WRAP_FLAG=true ;; -*) _BASHUNIT_BASE64_WRAP_FLAG=false ;; -esac -unset _bashunit_base64_help - -# Wire sentinel for an empty base64 payload. base64 of "" is "", which gets lost -# in line parsing, so encode_base64 emits this token and both decode sites map it -# back to "". Single source of truth keeps the encode (helpers.sh) and decode -# (helpers.sh, runner/payload.sh) sides byte-identical. -# shellcheck disable=SC2034 # read cross-file in helpers.sh and runner/payload.sh -_BASHUNIT_BASE64_EMPTY_SENTINEL="_BASHUNIT_EMPTY_" - -_BASHUNIT_TESTS_PASSED=0 -_BASHUNIT_TESTS_FAILED=0 -_BASHUNIT_TESTS_SKIPPED=0 -_BASHUNIT_TESTS_INCOMPLETE=0 -_BASHUNIT_TESTS_SNAPSHOT=0 -_BASHUNIT_TESTS_RISKY=0 -_BASHUNIT_ASSERTIONS_PASSED=0 -_BASHUNIT_ASSERTIONS_FAILED=0 -_BASHUNIT_ASSERTIONS_SKIPPED=0 -_BASHUNIT_ASSERTIONS_INCOMPLETE=0 -_BASHUNIT_ASSERTIONS_SNAPSHOT=0 -_BASHUNIT_DUPLICATED_FUNCTION_NAMES="" -_BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES="" -_BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND=false -_BASHUNIT_TEST_OUTPUT="" -_BASHUNIT_TEST_TITLE="" -_BASHUNIT_TEST_EXIT_CODE=0 -_BASHUNIT_TEST_HOOK_FAILURE="" -_BASHUNIT_TEST_HOOK_MESSAGE="" -_BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="" -_BASHUNIT_ASSERTION_FAILED_IN_TEST=0 - -function bashunit::state::get_tests_passed() { - echo "$_BASHUNIT_TESTS_PASSED" -} - -function bashunit::state::add_tests_passed() { - ((_BASHUNIT_TESTS_PASSED++)) || true -} - -function bashunit::state::get_tests_failed() { - echo "$_BASHUNIT_TESTS_FAILED" -} - -function bashunit::state::add_tests_failed() { - ((_BASHUNIT_TESTS_FAILED++)) || true -} - -function bashunit::state::get_tests_skipped() { - echo "$_BASHUNIT_TESTS_SKIPPED" -} - -function bashunit::state::add_tests_skipped() { - ((_BASHUNIT_TESTS_SKIPPED++)) || true -} - -function bashunit::state::get_tests_incomplete() { - echo "$_BASHUNIT_TESTS_INCOMPLETE" -} - -function bashunit::state::add_tests_incomplete() { - ((_BASHUNIT_TESTS_INCOMPLETE++)) || true -} - -function bashunit::state::get_tests_snapshot() { - echo "$_BASHUNIT_TESTS_SNAPSHOT" -} - -function bashunit::state::add_tests_snapshot() { - ((_BASHUNIT_TESTS_SNAPSHOT++)) || true -} - -function bashunit::state::get_tests_risky() { - echo "$_BASHUNIT_TESTS_RISKY" -} - -function bashunit::state::add_tests_risky() { - ((_BASHUNIT_TESTS_RISKY++)) || true -} - -function bashunit::state::get_assertions_passed() { - echo "$_BASHUNIT_ASSERTIONS_PASSED" -} - -function bashunit::state::add_assertions_passed() { - # Cheap global test first: the function call only happens while a - # bashunit::assert_once marker is open, keeping the per-assertion path flat. - if [ "${_BASHUNIT_ASSERT_ONCE_ACTIVE:-0}" -eq 1 ]; then - bashunit::assert::once_is_absorbing && return 0 - fi - ((_BASHUNIT_ASSERTIONS_PASSED++)) || true -} - -function bashunit::state::get_assertions_failed() { - echo "$_BASHUNIT_ASSERTIONS_FAILED" -} - -function bashunit::state::add_assertions_failed() { - ((_BASHUNIT_ASSERTIONS_FAILED++)) || true -} - -function bashunit::state::get_assertions_skipped() { - echo "$_BASHUNIT_ASSERTIONS_SKIPPED" -} - -function bashunit::state::add_assertions_skipped() { - ((_BASHUNIT_ASSERTIONS_SKIPPED++)) || true -} - -function bashunit::state::get_assertions_incomplete() { - echo "$_BASHUNIT_ASSERTIONS_INCOMPLETE" -} - -function bashunit::state::add_assertions_incomplete() { - ((_BASHUNIT_ASSERTIONS_INCOMPLETE++)) || true -} - -function bashunit::state::get_assertions_snapshot() { - echo "$_BASHUNIT_ASSERTIONS_SNAPSHOT" -} - -function bashunit::state::add_assertions_snapshot() { - ((_BASHUNIT_ASSERTIONS_SNAPSHOT++)) || true -} - -function bashunit::state::is_duplicated_test_functions_found() { - echo "$_BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND" -} - -function bashunit::state::set_duplicated_test_functions_found() { - _BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND=true -} - -function bashunit::state::get_duplicated_function_names() { - echo "$_BASHUNIT_DUPLICATED_FUNCTION_NAMES" -} - -function bashunit::state::set_duplicated_function_names() { - _BASHUNIT_DUPLICATED_FUNCTION_NAMES="$1" -} - -function bashunit::state::get_file_with_duplicated_function_names() { - echo "$_BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES" -} - -function bashunit::state::set_file_with_duplicated_function_names() { - _BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES="$1" -} - -function bashunit::state::add_test_output() { - _BASHUNIT_TEST_OUTPUT="$_BASHUNIT_TEST_OUTPUT$1" -} - -function bashunit::state::set_test_exit_code() { - _BASHUNIT_TEST_EXIT_CODE="$1" -} - -function bashunit::state::set_test_title() { - _BASHUNIT_TEST_TITLE="$1" -} - -function bashunit::state::reset_test_title() { - _BASHUNIT_TEST_TITLE="" -} - -function bashunit::state::set_current_test_interpolated_function_name() { - _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="$1" -} - -function bashunit::state::reset_current_test_interpolated_function_name() { - _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="" -} - -function bashunit::state::set_test_hook_failure() { - _BASHUNIT_TEST_HOOK_FAILURE="$1" -} - -function bashunit::state::set_test_hook_message() { - _BASHUNIT_TEST_HOOK_MESSAGE="$1" -} - -function bashunit::state::mark_assertion_failed_in_test() { - _BASHUNIT_ASSERTION_FAILED_IN_TEST=1 -} - -function bashunit::state::set_duplicated_functions_merged() { - bashunit::state::set_duplicated_test_functions_found - bashunit::state::set_file_with_duplicated_function_names "$1" - bashunit::state::set_duplicated_function_names "$2" -} - -function bashunit::state::initialize_assertions_count() { - _BASHUNIT_ASSERTIONS_PASSED=0 - _BASHUNIT_ASSERTIONS_FAILED=0 - _BASHUNIT_ASSERTIONS_SKIPPED=0 - _BASHUNIT_ASSERTIONS_INCOMPLETE=0 - _BASHUNIT_ASSERTIONS_SNAPSHOT=0 - _BASHUNIT_TEST_OUTPUT="" - _BASHUNIT_TEST_TITLE="" - _BASHUNIT_TEST_HOOK_FAILURE="" - _BASHUNIT_TEST_HOOK_MESSAGE="" - _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 - bashunit::assert::once_reset -} - -# base64-encodes a field, writing the result into _BASHUNIT_STATE_ENCODED_OUT. -# Empty values (the common case for title/hook message, and output on a passing -# test) encode to an empty field with no base64 fork (#762). base64 of "" is "" -# anyway, so this stays wire-compatible. -_BASHUNIT_STATE_ENCODED_OUT="" -function bashunit::state::encode_field() { - local value=$1 - if [ -z "$value" ]; then - _BASHUNIT_STATE_ENCODED_OUT="" - return - fi - if [ "$_BASHUNIT_BASE64_WRAP_FLAG" = true ]; then - # Alpine requires the -w 0 option to avoid wrapping - _BASHUNIT_STATE_ENCODED_OUT=$(echo -n "$value" | base64 -w 0) - else - _BASHUNIT_STATE_ENCODED_OUT=$(echo -n "$value" | base64) - fi -} - -function bashunit::state::export_subshell_context() { - local encoded_test_output - local encoded_test_title - local encoded_test_hook_message - - bashunit::state::encode_field "$_BASHUNIT_TEST_OUTPUT" - encoded_test_output=$_BASHUNIT_STATE_ENCODED_OUT - bashunit::state::encode_field "$_BASHUNIT_TEST_TITLE" - encoded_test_title=$_BASHUNIT_STATE_ENCODED_OUT - bashunit::state::encode_field "$_BASHUNIT_TEST_HOOK_MESSAGE" - encoded_test_hook_message=$_BASHUNIT_STATE_ENCODED_OUT - - # Emit the encoded result payload with `printf` (a builtin) instead of a - # `cat < state.sh call cycle and stops -# the format from being described in two places. -# -# Arguments: $1 - the run's parallel temp directory -## -function bashunit::state::aggregate_parallel_results() { - local temp_dir_parallel_test_suite=$1 - local IFS=$' \t\n' - - bashunit::internal_log "aggregate_parallel_results" "dir:$temp_dir_parallel_test_suite" - - local total_failed=0 - local total_passed=0 - local total_skipped=0 - local total_incomplete=0 - local total_snapshot=0 - - local script_dir="" - for script_dir in "$temp_dir_parallel_test_suite"/*; do - shopt -s nullglob - # Bash 3.0 compatible: separate declaration and assignment for arrays - local result_files - result_files=("$script_dir"/*.result) - shopt -u nullglob - - if [ ${#result_files[@]} -eq 0 ]; then - printf "%sNo tests found%s" "$_BASHUNIT_COLOR_SKIPPED" "$_BASHUNIT_COLOR_DEFAULT" - continue - fi - - local result_file="" - for result_file in "${result_files[@]+"${result_files[@]}"}"; do - local result_line - result_line=$(<"$result_file") - result_line="${result_line##*$'\n'}" - - local failed="${result_line##*##ASSERTIONS_FAILED=}" - failed="${failed%%##*}" - failed=${failed:-0} - - local passed="${result_line##*##ASSERTIONS_PASSED=}" - passed="${passed%%##*}" - passed=${passed:-0} - - local skipped="${result_line##*##ASSERTIONS_SKIPPED=}" - skipped="${skipped%%##*}" - skipped=${skipped:-0} - - local incomplete="${result_line##*##ASSERTIONS_INCOMPLETE=}" - incomplete="${incomplete%%##*}" - incomplete=${incomplete:-0} - - local snapshot="${result_line##*##ASSERTIONS_SNAPSHOT=}" - snapshot="${snapshot%%##*}" - snapshot=${snapshot:-0} - - local exit_code="${result_line##*##TEST_EXIT_CODE=}" - exit_code="${exit_code%%##*}" - exit_code=${exit_code:-0} - - # A truncated or non-payload .result line leaves every ##KEY= strip a - # no-op, so these fields hold arbitrary text. `$(( ))` on such text is a - # fatal arithmetic syntax error and `[ -gt ]` reports "integer expression - # expected", so an unreadable result must degrade to zeros rather than - # abort the aggregation. One `case` over the concatenation, no fork. - case "$failed$passed$skipped$incomplete$snapshot$exit_code" in - *[!0-9]*) - failed=0 passed=0 skipped=0 incomplete=0 snapshot=0 - # An unparseable result is a failed test, not a silently passing one. - exit_code=1 - bashunit::internal_log "aggregate_parallel_results" "unparseable result file:$result_file" - ;; - esac - - # Add to the total counts - total_failed=$((total_failed + failed)) - total_passed=$((total_passed + passed)) - total_skipped=$((total_skipped + skipped)) - total_incomplete=$((total_incomplete + incomplete)) - total_snapshot=$((total_snapshot + snapshot)) - - if [ "${failed:-0}" -gt 0 ]; then - bashunit::state::add_tests_failed - continue - fi - - if [ "${exit_code:-0}" -ne 0 ]; then - bashunit::state::add_tests_failed - continue - fi - - if [ "${snapshot:-0}" -gt 0 ]; then - bashunit::state::add_tests_snapshot - continue - fi - - if [ "${incomplete:-0}" -gt 0 ]; then - bashunit::state::add_tests_incomplete - continue - fi - - if [ "${skipped:-0}" -gt 0 ]; then - bashunit::state::add_tests_skipped - continue - fi - - # Check for risky test (zero assertions, no error) - local total_for_test=$((failed + passed + skipped + incomplete + snapshot)) - if [ "$total_for_test" -eq 0 ] && [ "${exit_code:-0}" -eq 0 ]; then - if bashunit::env::is_fail_on_risky_enabled; then - bashunit::state::add_tests_failed - else - bashunit::state::add_tests_risky - fi - continue - fi - - bashunit::state::add_tests_passed - done - done - - export _BASHUNIT_ASSERTIONS_FAILED=$total_failed - export _BASHUNIT_ASSERTIONS_PASSED=$total_passed - export _BASHUNIT_ASSERTIONS_SKIPPED=$total_skipped - export _BASHUNIT_ASSERTIONS_INCOMPLETE=$total_incomplete - export _BASHUNIT_ASSERTIONS_SNAPSHOT=$total_snapshot - - bashunit::internal_log "aggregate_totals" \ - "failed:$total_failed" \ - "passed:$total_passed" \ - "skipped:$total_skipped" \ - "incomplete:$total_incomplete" \ - "snapshot:$total_snapshot" -} - diff --git a/src/state/context.sh b/src/state/context.sh new file mode 100644 index 00000000..5ba54c1b --- /dev/null +++ b/src/state/context.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Per-test context: output buffer, exit code, title, hook failure, and the per-test reset. + +_BASHUNIT_TEST_OUTPUT="" +_BASHUNIT_TEST_TITLE="" +_BASHUNIT_TEST_EXIT_CODE=0 +_BASHUNIT_TEST_HOOK_FAILURE="" +_BASHUNIT_TEST_HOOK_MESSAGE="" +_BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="" +_BASHUNIT_ASSERTION_FAILED_IN_TEST=0 + + +function bashunit::state::add_test_output() { + _BASHUNIT_TEST_OUTPUT="$_BASHUNIT_TEST_OUTPUT$1" +} + + +function bashunit::state::set_test_exit_code() { + _BASHUNIT_TEST_EXIT_CODE="$1" +} + + +function bashunit::state::set_test_title() { + _BASHUNIT_TEST_TITLE="$1" +} + + +function bashunit::state::reset_test_title() { + _BASHUNIT_TEST_TITLE="" +} + + +function bashunit::state::set_current_test_interpolated_function_name() { + _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="$1" +} + + +function bashunit::state::reset_current_test_interpolated_function_name() { + _BASHUNIT_CURRENT_TEST_INTERPOLATED_NAME="" +} + + +function bashunit::state::set_test_hook_failure() { + _BASHUNIT_TEST_HOOK_FAILURE="$1" +} + + +function bashunit::state::set_test_hook_message() { + _BASHUNIT_TEST_HOOK_MESSAGE="$1" +} + + +function bashunit::state::mark_assertion_failed_in_test() { + _BASHUNIT_ASSERTION_FAILED_IN_TEST=1 +} + + +function bashunit::state::initialize_assertions_count() { + _BASHUNIT_ASSERTIONS_PASSED=0 + _BASHUNIT_ASSERTIONS_FAILED=0 + _BASHUNIT_ASSERTIONS_SKIPPED=0 + _BASHUNIT_ASSERTIONS_INCOMPLETE=0 + _BASHUNIT_ASSERTIONS_SNAPSHOT=0 + _BASHUNIT_TEST_OUTPUT="" + _BASHUNIT_TEST_TITLE="" + _BASHUNIT_TEST_HOOK_FAILURE="" + _BASHUNIT_TEST_HOOK_MESSAGE="" + _BASHUNIT_ASSERTION_FAILED_IN_TEST=0 + bashunit::assert::once_reset +} + +# base64-encodes a field, writing the result into _BASHUNIT_STATE_ENCODED_OUT. +# Empty values (the common case for title/hook message, and output on a passing +# test) encode to an empty field with no base64 fork (#762). base64 of "" is "" +# anyway, so this stays wire-compatible. +_BASHUNIT_STATE_ENCODED_OUT="" diff --git a/src/state/counters.sh b/src/state/counters.sh new file mode 100644 index 00000000..8953d069 --- /dev/null +++ b/src/state/counters.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +# Test and assertion tallies: the _BASHUNIT_TESTS_*/_ASSERTIONS_* globals and their get/add accessors. + +_BASHUNIT_TESTS_PASSED=0 +_BASHUNIT_TESTS_FAILED=0 +_BASHUNIT_TESTS_SKIPPED=0 +_BASHUNIT_TESTS_INCOMPLETE=0 +_BASHUNIT_TESTS_SNAPSHOT=0 +_BASHUNIT_TESTS_RISKY=0 +_BASHUNIT_ASSERTIONS_PASSED=0 +_BASHUNIT_ASSERTIONS_FAILED=0 +_BASHUNIT_ASSERTIONS_SKIPPED=0 +_BASHUNIT_ASSERTIONS_INCOMPLETE=0 +_BASHUNIT_ASSERTIONS_SNAPSHOT=0 + +function bashunit::state::get_tests_passed() { + echo "$_BASHUNIT_TESTS_PASSED" +} + + +function bashunit::state::add_tests_passed() { + ((_BASHUNIT_TESTS_PASSED++)) || true +} + + +function bashunit::state::get_tests_failed() { + echo "$_BASHUNIT_TESTS_FAILED" +} + + +function bashunit::state::add_tests_failed() { + ((_BASHUNIT_TESTS_FAILED++)) || true +} + + +function bashunit::state::get_tests_skipped() { + echo "$_BASHUNIT_TESTS_SKIPPED" +} + + +function bashunit::state::add_tests_skipped() { + ((_BASHUNIT_TESTS_SKIPPED++)) || true +} + + +function bashunit::state::get_tests_incomplete() { + echo "$_BASHUNIT_TESTS_INCOMPLETE" +} + + +function bashunit::state::add_tests_incomplete() { + ((_BASHUNIT_TESTS_INCOMPLETE++)) || true +} + + +function bashunit::state::get_tests_snapshot() { + echo "$_BASHUNIT_TESTS_SNAPSHOT" +} + + +function bashunit::state::add_tests_snapshot() { + ((_BASHUNIT_TESTS_SNAPSHOT++)) || true +} + + +function bashunit::state::get_tests_risky() { + echo "$_BASHUNIT_TESTS_RISKY" +} + + +function bashunit::state::add_tests_risky() { + ((_BASHUNIT_TESTS_RISKY++)) || true +} + + +function bashunit::state::get_assertions_passed() { + echo "$_BASHUNIT_ASSERTIONS_PASSED" +} + + +function bashunit::state::add_assertions_passed() { + # Cheap global test first: the function call only happens while a + # bashunit::assert_once marker is open, keeping the per-assertion path flat. + if [ "${_BASHUNIT_ASSERT_ONCE_ACTIVE:-0}" -eq 1 ]; then + bashunit::assert::once_is_absorbing && return 0 + fi + ((_BASHUNIT_ASSERTIONS_PASSED++)) || true +} + + +function bashunit::state::get_assertions_failed() { + echo "$_BASHUNIT_ASSERTIONS_FAILED" +} + + +function bashunit::state::add_assertions_failed() { + ((_BASHUNIT_ASSERTIONS_FAILED++)) || true +} + + +function bashunit::state::get_assertions_skipped() { + echo "$_BASHUNIT_ASSERTIONS_SKIPPED" +} + + +function bashunit::state::add_assertions_skipped() { + ((_BASHUNIT_ASSERTIONS_SKIPPED++)) || true +} + + +function bashunit::state::get_assertions_incomplete() { + echo "$_BASHUNIT_ASSERTIONS_INCOMPLETE" +} + + +function bashunit::state::add_assertions_incomplete() { + ((_BASHUNIT_ASSERTIONS_INCOMPLETE++)) || true +} + + +function bashunit::state::get_assertions_snapshot() { + echo "$_BASHUNIT_ASSERTIONS_SNAPSHOT" +} + + +function bashunit::state::add_assertions_snapshot() { + ((_BASHUNIT_ASSERTIONS_SNAPSHOT++)) || true +} + + diff --git a/src/state/duplicates.sh b/src/state/duplicates.sh new file mode 100644 index 00000000..beff74d2 --- /dev/null +++ b/src/state/duplicates.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# Duplicate test-function detection state. + +_BASHUNIT_DUPLICATED_FUNCTION_NAMES="" +_BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES="" +_BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND=false + +function bashunit::state::is_duplicated_test_functions_found() { + echo "$_BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND" +} + + +function bashunit::state::set_duplicated_test_functions_found() { + _BASHUNIT_DUPLICATED_TEST_FUNCTIONS_FOUND=true +} + + +function bashunit::state::get_duplicated_function_names() { + echo "$_BASHUNIT_DUPLICATED_FUNCTION_NAMES" +} + + +function bashunit::state::set_duplicated_function_names() { + _BASHUNIT_DUPLICATED_FUNCTION_NAMES="$1" +} + + +function bashunit::state::get_file_with_duplicated_function_names() { + echo "$_BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES" +} + + +function bashunit::state::set_file_with_duplicated_function_names() { + _BASHUNIT_FILE_WITH_DUPLICATED_FUNCTION_NAMES="$1" +} + + +function bashunit::state::set_duplicated_functions_merged() { + bashunit::state::set_duplicated_test_functions_found + bashunit::state::set_file_with_duplicated_function_names "$1" + bashunit::state::set_duplicated_function_names "$2" +} + diff --git a/src/state/index.sh b/src/state/index.sh new file mode 100644 index 00000000..c0eb33bd --- /dev/null +++ b/src/state/index.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +# Entry point for the src/state/ module: only `source` lines and comments belong +# here. build.sh emits a file's body before recursing into its `source` lines, so +# any statement here would run before its dependencies in the built binary +# (adrs/adr-010-src-module-directories.md). +# +# counters.sh and duplicates.sh declare the globals context.sh's per-test reset +# clears, so they are sourced first. +source "$BASHUNIT_ROOT_DIR/src/state/counters.sh" +source "$BASHUNIT_ROOT_DIR/src/state/duplicates.sh" +source "$BASHUNIT_ROOT_DIR/src/state/context.sh" +source "$BASHUNIT_ROOT_DIR/src/state/payload.sh" +source "$BASHUNIT_ROOT_DIR/src/state/parallel.sh" diff --git a/src/state/parallel.sh b/src/state/parallel.sh new file mode 100644 index 00000000..59607d9f --- /dev/null +++ b/src/state/parallel.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash + +# Aggregating per-test result files after a --parallel run. + +## +# Folds every parallel worker's `.result` payload back into this shell's +# counters and assertion totals. +# +# Lives here rather than in parallel.sh because it decodes the very payload +# `bashunit::state::export_subshell_context` writes: keeping the encoder and the +# decoder in one module removes the parallel.sh <-> state.sh call cycle and stops +# the format from being described in two places. +# +# Arguments: $1 - the run's parallel temp directory +## +function bashunit::state::aggregate_parallel_results() { + local temp_dir_parallel_test_suite=$1 + local IFS=$' \t\n' + + bashunit::internal_log "aggregate_parallel_results" "dir:$temp_dir_parallel_test_suite" + + local total_failed=0 + local total_passed=0 + local total_skipped=0 + local total_incomplete=0 + local total_snapshot=0 + + local script_dir="" + for script_dir in "$temp_dir_parallel_test_suite"/*; do + shopt -s nullglob + # Bash 3.0 compatible: separate declaration and assignment for arrays + local result_files + result_files=("$script_dir"/*.result) + shopt -u nullglob + + if [ ${#result_files[@]} -eq 0 ]; then + printf "%sNo tests found%s" "$_BASHUNIT_COLOR_SKIPPED" "$_BASHUNIT_COLOR_DEFAULT" + continue + fi + + local result_file="" + for result_file in "${result_files[@]+"${result_files[@]}"}"; do + local result_line + result_line=$(<"$result_file") + result_line="${result_line##*$'\n'}" + + local failed="${result_line##*##ASSERTIONS_FAILED=}" + failed="${failed%%##*}" + failed=${failed:-0} + + local passed="${result_line##*##ASSERTIONS_PASSED=}" + passed="${passed%%##*}" + passed=${passed:-0} + + local skipped="${result_line##*##ASSERTIONS_SKIPPED=}" + skipped="${skipped%%##*}" + skipped=${skipped:-0} + + local incomplete="${result_line##*##ASSERTIONS_INCOMPLETE=}" + incomplete="${incomplete%%##*}" + incomplete=${incomplete:-0} + + local snapshot="${result_line##*##ASSERTIONS_SNAPSHOT=}" + snapshot="${snapshot%%##*}" + snapshot=${snapshot:-0} + + local exit_code="${result_line##*##TEST_EXIT_CODE=}" + exit_code="${exit_code%%##*}" + exit_code=${exit_code:-0} + + # A truncated or non-payload .result line leaves every ##KEY= strip a + # no-op, so these fields hold arbitrary text. `$(( ))` on such text is a + # fatal arithmetic syntax error and `[ -gt ]` reports "integer expression + # expected", so an unreadable result must degrade to zeros rather than + # abort the aggregation. One `case` over the concatenation, no fork. + case "$failed$passed$skipped$incomplete$snapshot$exit_code" in + *[!0-9]*) + failed=0 passed=0 skipped=0 incomplete=0 snapshot=0 + # An unparseable result is a failed test, not a silently passing one. + exit_code=1 + bashunit::internal_log "aggregate_parallel_results" "unparseable result file:$result_file" + ;; + esac + + # Add to the total counts + total_failed=$((total_failed + failed)) + total_passed=$((total_passed + passed)) + total_skipped=$((total_skipped + skipped)) + total_incomplete=$((total_incomplete + incomplete)) + total_snapshot=$((total_snapshot + snapshot)) + + if [ "${failed:-0}" -gt 0 ]; then + bashunit::state::add_tests_failed + continue + fi + + if [ "${exit_code:-0}" -ne 0 ]; then + bashunit::state::add_tests_failed + continue + fi + + if [ "${snapshot:-0}" -gt 0 ]; then + bashunit::state::add_tests_snapshot + continue + fi + + if [ "${incomplete:-0}" -gt 0 ]; then + bashunit::state::add_tests_incomplete + continue + fi + + if [ "${skipped:-0}" -gt 0 ]; then + bashunit::state::add_tests_skipped + continue + fi + + # Check for risky test (zero assertions, no error) + local total_for_test=$((failed + passed + skipped + incomplete + snapshot)) + if [ "$total_for_test" -eq 0 ] && [ "${exit_code:-0}" -eq 0 ]; then + if bashunit::env::is_fail_on_risky_enabled; then + bashunit::state::add_tests_failed + else + bashunit::state::add_tests_risky + fi + continue + fi + + bashunit::state::add_tests_passed + done + done + + export _BASHUNIT_ASSERTIONS_FAILED=$total_failed + export _BASHUNIT_ASSERTIONS_PASSED=$total_passed + export _BASHUNIT_ASSERTIONS_SKIPPED=$total_skipped + export _BASHUNIT_ASSERTIONS_INCOMPLETE=$total_incomplete + export _BASHUNIT_ASSERTIONS_SNAPSHOT=$total_snapshot + + bashunit::internal_log "aggregate_totals" \ + "failed:$total_failed" \ + "passed:$total_passed" \ + "skipped:$total_skipped" \ + "incomplete:$total_incomplete" \ + "snapshot:$total_snapshot" +} + diff --git a/src/state/payload.sh b/src/state/payload.sh new file mode 100644 index 00000000..de96369c --- /dev/null +++ b/src/state/payload.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +# Encoding the per-test result payload a capture subshell hands back, plus the base64 capability probe. + +# Cache base64 -w flag support (Alpine needs -w 0, macOS does not support -w). +# Scrape `base64 --help` once and match with a shell `case` instead of piping +# into a `grep` fork — same detection, one fewer fork per cold start. +_bashunit_base64_help="$(base64 --help 2>&1 || true)" +case "$_bashunit_base64_help" in +*-w*) _BASHUNIT_BASE64_WRAP_FLAG=true ;; +*) _BASHUNIT_BASE64_WRAP_FLAG=false ;; +esac +unset _bashunit_base64_help + +# Wire sentinel for an empty base64 payload. base64 of "" is "", which gets lost +# in line parsing, so encode_base64 emits this token and both decode sites map it +# back to "". Single source of truth keeps the encode (helpers.sh) and decode +# (helpers.sh, runner/payload.sh) sides byte-identical. +# shellcheck disable=SC2034 # read cross-file in helpers.sh and runner/payload.sh +_BASHUNIT_BASE64_EMPTY_SENTINEL="_BASHUNIT_EMPTY_" + +function bashunit::state::encode_field() { + local value=$1 + if [ -z "$value" ]; then + _BASHUNIT_STATE_ENCODED_OUT="" + return + fi + if [ "$_BASHUNIT_BASE64_WRAP_FLAG" = true ]; then + # Alpine requires the -w 0 option to avoid wrapping + _BASHUNIT_STATE_ENCODED_OUT=$(echo -n "$value" | base64 -w 0) + else + _BASHUNIT_STATE_ENCODED_OUT=$(echo -n "$value" | base64) + fi +} + + +function bashunit::state::export_subshell_context() { + local encoded_test_output + local encoded_test_title + local encoded_test_hook_message + + bashunit::state::encode_field "$_BASHUNIT_TEST_OUTPUT" + encoded_test_output=$_BASHUNIT_STATE_ENCODED_OUT + bashunit::state::encode_field "$_BASHUNIT_TEST_TITLE" + encoded_test_title=$_BASHUNIT_STATE_ENCODED_OUT + bashunit::state::encode_field "$_BASHUNIT_TEST_HOOK_MESSAGE" + encoded_test_hook_message=$_BASHUNIT_STATE_ENCODED_OUT + + # Emit the encoded result payload with `printf` (a builtin) instead of a + # `cat < parallel call cycle #862 broke. Grepped rather than exercised, so the -# edge cannot come back through a path no test happens to cover. +# The state module owns counters and the per-test payload. It rendered progress +# lines until #868, which was both a layering inversion and the sole reason for +# the state -> parallel call cycle #862 broke. Grepped rather than exercised, so +# the edge cannot come back through a path no test happens to cover. Globbed over +# the whole module: pointed at a single file it would pass vacuously the moment +# that file is split or renamed. function test_state_does_not_call_the_renderer_or_parallel() { # `|| true`: no match is the passing case, and grep exiting 1 would sink the # whole pipeline under --strict's pipefail. local offenders offenders=$({ "$GREP" -oE "bashunit::(console_results|console_header|parallel|runner)::[a-z_]+" \ - src/state.sh || true; } | sort -u | tr '\n' ' ') + src/state/*.sh || true; } | sort -u | tr '\n' ' ') assert_same "" "${offenders% }" }