diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6fb940..6260e4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ ### 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) - 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 diff --git a/docs/command-line.md b/docs/command-line.md index fc74fb28..3514b01b 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -1598,7 +1598,7 @@ Reports an error if the two variables are not equal... > `bashunit init [directory]` -Initialize a new test directory with sample files. +Initialize a new test directory with sample files. A directory containing whitespace gets a separate, quoted workflow command so CI can run it without a committed `.env` file. ::: code-group ```bash [Examples] diff --git a/src/assert/snapshot.sh b/src/assert/snapshot.sh index 906c87c3..cdf92f8d 100644 --- a/src/assert/snapshot.sh +++ b/src/assert/snapshot.sh @@ -402,7 +402,7 @@ function bashunit::snapshot::initialize() { local path="$1" local content="$2" mkdir -p "$(dirname "$path")" - echo "$content" >"$path" + printf '%s\n' "$content" >"$path" bashunit::state::add_assertions_snapshot } @@ -431,7 +431,7 @@ function bashunit::snapshot::update() { ;; esac - echo "$actual" >"$path" + printf '%s\n' "$actual" >"$path" bashunit::state::add_assertions_snapshot return 0 } diff --git a/src/cli/init.sh b/src/cli/init.sh index ba108083..084ad72c 100644 --- a/src/cli/init.sh +++ b/src/cli/init.sh @@ -69,9 +69,27 @@ jobs: steps: - uses: actions/checkout@v4 - uses: TypedDevs/bashunit@v0 +SH + case "$tests_dir" in + *[[:space:]]*) + local workflow_path + workflow_path=$(printf '%q' "$tests_dir") + local expression_start='${{' literal_expression_start='\044{{' + # Actions expands expressions before Bash parses its quotes. + workflow_path=${workflow_path//"$expression_start"/$literal_expression_start} + cat >>"$workflow_file" <>"$workflow_file" < Created $workflow_file" fi diff --git a/src/helper/discovery.sh b/src/helper/discovery.sh index ac0d92e7..cac6cf2a 100644 --- a/src/helper/discovery.sh +++ b/src/helper/discovery.sh @@ -17,23 +17,18 @@ function bashunit::helper::check_duplicate_functions() { # list itself, so no `sort` fork is needed to keep the output deterministic. local duplicates duplicates=$(awk ' - /^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)[[:space:]]*(\{|$)/ { - for (i = 1; i <= NF; i++) { - if ($i ~ /^test[a-zA-Z_][a-zA-Z0-9_]*\(\)$/) { - name = $i - gsub(/\(\)/, "", name) - if (++seen[name] == 2) { - dup[name] = 1 - } - # Recorded for every occurrence, the first included: the report needs - # both definitions to be worth anything. - if (lines[name] == "") { - lines[name] = FNR - } else { - lines[name] = lines[name] ", " FNR - } - break - } + /^[[:space:]]*function[[:space:]]+test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*(\(\)|\{|#|\(|$)/ || + /^[[:space:]]*test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)/ { + name = $0 + sub(/^[[:space:]]*(function[[:space:]]+)?/, "", name) + sub(/[[:space:]({#].*/, "", name) + if (++seen[name] == 2) { + dup[name] = 1 + } + if (lines[name] == "") { + lines[name] = FNR + } else { + lines[name] = lines[name] ", " FNR } } END { diff --git a/src/helper/provider.sh b/src/helper/provider.sh index fae89091..34a4e6d0 100644 --- a/src/helper/provider.sh +++ b/src/helper/provider.sh @@ -150,11 +150,12 @@ function bashunit::helper::build_provider_map() { # Any other comment keeps the block open, the same rule @tag follows. /^[[:space:]]*#/ { next } { - is_fn = match($0, /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/) + is_fn = match($0, /^[[:space:]]*function[[:space:]]+[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*(\(\)|\{|#|\(|$)/) || + match($0, /^[[:space:]]*[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/) if (is_fn) { fn = $0 sub(/^[[:space:]]*(function[[:space:]]+)?/, "", fn) - sub(/[[:space:]]*\(\).*/, "", fn) + sub(/[[:space:]({#].*/, "", fn) } # A definition at column 0 is one this scan can be sure the file defines @@ -162,7 +163,7 @@ function bashunit::helper::build_provider_map() { # Anything that could define a function this scan cannot see -- eval, # a nested source, an indented (so conditional or nested) definition -- # marks the file dynamic and sends the count back to sourcing. - if (match($0, /^(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/)) { + if (is_fn && $0 !~ /^[[:space:]]/) { # Accumulated, not emitted per definition: the read loop below runs # once per row, and a row per function cost about as much as counting # statically saved. diff --git a/src/helper/tags.sh b/src/helper/tags.sh index 5fd59023..2746f863 100644 --- a/src/helper/tags.sh +++ b/src/helper/tags.sh @@ -89,10 +89,11 @@ function bashunit::helper::build_tags_map() { next } /^[[:space:]]*#/ { next } - /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/ { + /^[[:space:]]*function[[:space:]]+[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*(\(\)|\{|#|\(|$)/ || + /^[[:space:]]*[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/ { fn = $0 sub(/^[[:space:]]*(function[[:space:]]+)?/, "", fn) - sub(/[[:space:]]*\(\).*/, "", fn) + sub(/[[:space:]({#].*/, "", fn) # Buffered rather than printed here so a `# @tags` line placed below the # functions still applies to them (single pass, order preserved). order[n] = fn diff --git a/tests/acceptance/bashunit_init_test.sh b/tests/acceptance/bashunit_init_test.sh index 6a776e87..a09db530 100644 --- a/tests/acceptance/bashunit_init_test.sh +++ b/tests/acceptance/bashunit_init_test.sh @@ -289,3 +289,37 @@ function test_a_healthy_env_flag_file_still_loads() { assert_same 0 "$ec" assert_contains "1 passed" "$output" } + +function provide_workflow_directories_with_whitespace() { + bashunit::data_set 'my tests' + bashunit::data_set $'my\ttests' + bashunit::data_set $'my\t${{ github.repository }}' + bashunit::data_set "my tests'\"; touch injected; #" +} + +# @data_provider provide_workflow_directories_with_whitespace +function test_generated_workflow_runs_the_whole_directory_without_an_env_file() { + local directory=$1 command output code=0 + (cd "$TMP_DIR" && "$BASHUNIT_PATH" init "$directory") >/dev/null + command=$(awk '/^ run: \|$/ { getline; sub(/^ /, ""); print; exit }' \ + "$TMP_DIR/.github/workflows/tests.yml") + assert_not_empty "$command" + assert_file_not_contains "$TMP_DIR/.github/workflows/tests.yml" '${{' + if [ -z "$command" ]; then + return + fi + mkdir -p "$TMP_DIR/lib" + printf '#!/usr/bin/env bash\nexec %q "$@"\n' "$BASHUNIT_PATH" >"$TMP_DIR/lib/bashunit" + chmod +x "$TMP_DIR/lib/bashunit" + rm "$TMP_DIR/.env" + output=$(cd "$TMP_DIR" && bash -c "$command" 2>&1) || code=$? + assert_same 0 "$code" + assert_contains '1 passed' "$output" + assert_file_not_exists "$TMP_DIR/injected" +} + +function test_generated_workflow_keeps_the_existing_args_for_a_simple_directory() { + (cd "$TMP_DIR" && "$BASHUNIT_PATH" init custom) >/dev/null + assert_file_contains "$TMP_DIR/.github/workflows/tests.yml" ' args: custom' + assert_file_not_contains "$TMP_DIR/.github/workflows/tests.yml" ' run: |' +} diff --git a/tests/acceptance/bashunit_test_function_names_test.sh b/tests/acceptance/bashunit_test_function_names_test.sh index 7ab03e9d..9debc804 100644 --- a/tests/acceptance/bashunit_test_function_names_test.sh +++ b/tests/acceptance/bashunit_test_function_names_test.sh @@ -74,3 +74,33 @@ function test_real_3() { assert_same 1 1; }') assert_contains "test_real_3" "$output" assert_not_contains "testdata_path" "$output" } + +function provide_definition_annotation_modes() { + bashunit::data_set --no-parallel + bashunit::data_set --parallel +} + +# @data_provider provide_definition_annotation_modes +function test_mixed_definition_forms_keep_annotations_tags_and_header_counts() { + local fixture="$WORKDIR/mixed_test.sh" + printf '%s\n' 'function provide_keyword_rows() { bashunit::data_set one; bashunit::data_set two; } +function test_keyword_with_parentheses() { assert_true true; } +test_only_parentheses() { assert_true true; } +# @data_provider provide_keyword_rows +function test_keyword_provider { assert_same 1 "$#"; } +# @skip not ready +function test_keyword_skipped { assert_true false; } +# @tag slow +function test_keyword_tagged { assert_true true; }' >"$fixture" + local output + output=$("$BASHUNIT_BIN" "$1" --skip-env-file --no-color "$fixture" 2>&1) + assert_successful_code "$?" + assert_contains 'Tests: 6' "$output" + assert_contains '5 passed' "$output" + assert_contains '1 skipped' "$output" + assert_contains '6 total' "$output" + output=$("$BASHUNIT_BIN" "$1" --skip-env-file --no-color --tag slow "$fixture" 2>&1) + assert_successful_code "$?" + assert_contains '1 passed' "$output" + assert_contains '1 total' "$output" +} diff --git a/tests/unit/assert/snapshot_test.sh b/tests/unit/assert/snapshot_test.sh index 724a5093..1eac6d28 100644 --- a/tests/unit/assert/snapshot_test.sh +++ b/tests/unit/assert/snapshot_test.sh @@ -363,3 +363,27 @@ function test_regex_metacharacters_around_a_placeholder_are_literal() { assert_empty "$(assert_match_snapshot 'cost $5.00 (x) Z' "$snapshot")" assert_not_empty "$(assert_match_snapshot 'cost 999 (x) Z' "$snapshot")" } + +function provide_snapshot_option_values() { + bashunit::data_set -n + bashunit::data_set -e + bashunit::data_set -E + bashunit::data_set -neE +} + +# @data_provider provide_snapshot_option_values +function test_snapshot_records_option_values_and_matches_again() { + local path="$(bashunit::temp_dir)/flag.snapshot" + assert_match_snapshot "$1" "$path" + assert_same "$1" "$(<"$path")" + assert_empty "$(assert_match_snapshot "$1" "$path")" +} + +# @data_provider provide_snapshot_option_values +function test_snapshot_update_writes_option_values() { + local path="$(bashunit::temp_dir)/flag.snapshot" + printf 'previous\n' >"$path" + BASHUNIT_SNAPSHOT_UPDATE=true bashunit::snapshot::update "$path" "$1" + assert_same "$1" "$(<"$path")" + assert_empty "$(assert_match_snapshot "$1" "$path")" +} diff --git a/tests/unit/helper/annotations_test.sh b/tests/unit/helper/annotations_test.sh index 6ab549ed..b3bc96a6 100644 --- a/tests/unit/helper/annotations_test.sh +++ b/tests/unit/helper/annotations_test.sh @@ -300,3 +300,34 @@ ${FN}() { :; }" assert_empty "$(compgen -A variable _BASHUNIT_METADATA_ANNOT_)" assert_empty "$(compgen -A variable _BASHUNIT_METADATA_PROVIDER_)" } + +function test_keyword_only_definitions_keep_provider_and_runtime_annotations() { + build_map "# @timeout 7 +# @retry 2 +# @data_provider rows +function ${FN} { + : +}" + bashunit::helper::provider_for_function "$FN" + assert_same rows "$_BASHUNIT_PROVIDER_FN_OUT" + bashunit::helper::annotations_for_function "$FN" + assert_same 7 "$_BASHUNIT_ANNOT_TIMEOUT_OUT" + assert_same 2 "$_BASHUNIT_ANNOT_RETRY_OUT" + assert_same false "$_BASHUNIT_PROVIDER_MAP_DYNAMIC" +} + +function test_keyword_only_definition_keeps_skip_reason() { + build_map "# @skip not ready +function ${OTHER_FN} +{ + : +}" + bashunit::helper::annotations_for_function "$OTHER_FN" + assert_same true "$_BASHUNIT_ANNOT_SKIP_OUT" + assert_same 'not ready' "$_BASHUNIT_ANNOT_REASON_OUT" +} + +function test_indented_keyword_only_definition_keeps_dynamic_counting() { + build_map " function ${FN} { :; }" + assert_same true "$_BASHUNIT_PROVIDER_MAP_DYNAMIC" +} diff --git a/tests/unit/helper/helper_test.sh b/tests/unit/helper/helper_test.sh index 4074a1b5..81fb98c9 100644 --- a/tests/unit/helper/helper_test.sh +++ b/tests/unit/helper/helper_test.sh @@ -145,6 +145,19 @@ function test_check_duplicate_functions_with_the_brace_on_the_next_line() { assert_contains "test_twice (lines 1, 5)" "$detail" } +function test_check_duplicate_functions_with_a_comment_before_the_brace() { + local file detail + file="$(bashunit::temp_file duplicate_comment_line).sh" + printf 'function %s() # first\n{\n :\n}\nfunction %s() # second\n{\n :\n}\n' \ + test_twice test_twice >"$file" + + detail=$( + bashunit::helper::check_duplicate_functions "$file" >/dev/null 2>&1 || true + bashunit::state::get_duplicated_function_details + ) + assert_contains "test_twice (lines 1, 5)" "$detail" +} + function test_check_duplicate_functions_without_duplicates() { local file file="$(bashunit::current_dir)/../fixtures/no_duplicate_functions.sh" @@ -1066,3 +1079,17 @@ function test_no_exclude_filter_keeps_previous_behaviour() { assert_same "prefix_alpha prefix_beta" \ "$(_with_exclude_filter "" "prefix" "" "${functions[*]}")" } + +function test_duplicate_keyword_only_definitions_are_reported() { + local file fn=test_duplicate_without_parens + file="$(bashunit::temp_file)" + printf 'function %s { :; }\nfunction %s\n{ :; }\n' "$fn" "$fn" >"$file" + assert_general_error "$(bashunit::helper::check_duplicate_functions "$file")" +} + +function test_duplicate_definitions_with_comments_and_subshell_bodies_are_reported() { + local file fn=test_duplicate_body_forms + file="$(bashunit::temp_file)" + printf 'function %s () # a note\n{ :; }\n%s() ( : )\n' "$fn" "$fn" >"$file" + assert_general_error "$(bashunit::helper::check_duplicate_functions "$file")" +} diff --git a/tests/unit/helper/tags_map_test.sh b/tests/unit/helper/tags_map_test.sh index 4908bf3e..d47c95ff 100644 --- a/tests/unit/helper/tags_map_test.sh +++ b/tests/unit/helper/tags_map_test.sh @@ -93,3 +93,17 @@ function test_singular_tag_is_still_function_scoped() { assert_same "" \ "$(tags_for "$FIXTURE_TAGS_MAP" "test_no_tags")" } + +function test_tag_applies_to_keyword_only_definition() { + local file fn=test_keyword_only_tagged + file="$(bashunit::temp_file)" + printf '# @tag slow\nfunction %s\n{ :; }\n' "$fn" >"$file" + assert_same slow "$(tags_for "$file" "$fn")" +} + +function test_tag_does_not_turn_a_bare_word_into_a_definition() { + local file fn=test_plain_word + file="$(bashunit::temp_file)" + printf '# @tag slow\n%s\n' "$fn" >"$file" + assert_empty "$(tags_for "$file" "$fn")" +}