Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
37b65bf
fix(runner): scan functions without parentheses
Chemaclass Oct 29, 2026
42a597d
fix(cli): quote spaced paths in init workflows
Chemaclass Oct 29, 2026
829476f
chore: merge preceding issue fixes
Chemaclass Oct 29, 2026
9d04d6b
chore: merge preceding issue fixes
Chemaclass Oct 29, 2026
61f11be
chore: merge provider stop correction
Chemaclass Oct 29, 2026
574fd8c
chore: merge provider stop correction
Chemaclass Oct 29, 2026
e4839ed
chore: merge static-count guard correction
Chemaclass Oct 29, 2026
4cd75c0
chore: merge static-count guard correction
Chemaclass Oct 29, 2026
39a29e4
chore: merge TAP test portability fix
Chemaclass Oct 29, 2026
d0f9c24
chore: merge TAP test portability fix
Chemaclass Oct 29, 2026
97dd25c
chore: merge earlier static guard ancestry
Chemaclass Oct 29, 2026
75ad251
chore: merge earlier static guard ancestry
Chemaclass Oct 29, 2026
e50b783
chore: merge regex pipe correction
Chemaclass Oct 29, 2026
c102e58
chore: merge regex pipe correction
Chemaclass Oct 29, 2026
cd7367d
chore: merge process-guide correction
Chemaclass Oct 29, 2026
02692d5
chore: merge process-guide correction
Chemaclass Oct 29, 2026
03101f1
chore: merge validated CI fixture fixes
Chemaclass Oct 30, 2026
b95439d
chore: merge validated CI fixture fixes
Chemaclass Oct 30, 2026
b74f3c5
chore: merge terminal fixture readiness fix
Chemaclass Oct 30, 2026
0fa6eeb
chore: merge terminal fixture readiness fix
Chemaclass Oct 30, 2026
253a741
chore: merge windows provider parsing fix
Chemaclass Oct 30, 2026
0047ac4
chore: merge windows provider parsing fix
Chemaclass Oct 30, 2026
c7db1b6
chore: merge contributor fix and terminal diagnostics
Chemaclass Oct 30, 2026
665120d
chore: merge contributor fix and terminal diagnostics
Chemaclass Oct 30, 2026
08ca2c3
chore: merge terminal phase budget correction
Chemaclass Oct 30, 2026
ded23d4
chore: merge terminal phase budget correction
Chemaclass Oct 30, 2026
9786464
Merge branch 'fix/1419-echo-provider-hash-row' into fix/1422-scan-fun…
Chemaclass Oct 30, 2026
9b5f77e
Merge branch 'fix/1422-scan-function-keyword-without-parens' into fix…
Chemaclass Oct 30, 2026
449a6c7
test(runner): cover comments before function braces
Chemaclass Oct 30, 2026
76fd056
Merge branch 'fix/1422-scan-function-keyword-without-parens' into fix…
Chemaclass Oct 30, 2026
86054cf
Merge pull request #1447 from TypedDevs/fix/1425-init-workflow-keeps-…
Chemaclass Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### Fixed

- 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)
- 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)
Expand Down
2 changes: 1 addition & 1 deletion docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
18 changes: 18 additions & 0 deletions src/cli/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<SH
- name: Run tests
run: |
lib/bashunit $workflow_path
SH
;;
*)
cat >>"$workflow_file" <<SH
with:
args: $tests_dir
SH
;;
esac
echo "> Created $workflow_file"
fi

Expand Down
29 changes: 12 additions & 17 deletions src/helper/discovery.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions src/helper/provider.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,19 +150,20 @@ 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
# unconditionally, which is what lets the header count without sourcing.
# 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.
Expand Down
5 changes: 3 additions & 2 deletions src/helper/tags.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/acceptance/bashunit_init_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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: |'
}
30 changes: 30 additions & 0 deletions tests/acceptance/bashunit_test_function_names_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
31 changes: 31 additions & 0 deletions tests/unit/helper/annotations_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
27 changes: 27 additions & 0 deletions tests/unit/helper/helper_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")"
}
14 changes: 14 additions & 0 deletions tests/unit/helper/tags_map_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
}
Loading