Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- Internal: Split `src/runner.sh` and `src/coverage.sh` into focused modules with no behavior change; see [ADR-010](adrs/adr-010-src-module-directories.md) (#924, #925)

### Fixed
- Runtime errors are recognised from the exit code when the diagnostic text cannot be matched. bash translates its messages, so under a Spanish or Japanese locale a genuine `command not found` was reported as a plain assertion failure; the same now applies when a test redirects the diagnostic away, where the message used to be empty (#998)
- A failing test whose output quotes a shell-error phrase is no longer also reported as a runtime `Error`. The classifier scanned the whole capture -- which includes bashunit's own failure rendering -- for strings like `command not found`, so one cause was reported twice. It now requires the source-and-line prefix bash puts on a real diagnostic (#992)
- `assert_have_been_called_times` and `assert_have_been_called_nth_with` report a usage error when their numeric argument is not a number, instead of leaking `[: my_cmd: integer expression expected` from inside bashunit. The common cause is swapping the count and the spy, which the message now names (#984)
- `assert_false` no longer passes when the command does not exist. Exit code 127 is non-zero, so a typo in the command name satisfied the assertion while testing nothing; 127 and 126 now fail both `assert_true` and `assert_false`, because they mean the command never ran
Expand Down
99 changes: 64 additions & 35 deletions src/runner/diagnostics.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,50 @@ function bashunit::runner::record_profile() {
# rather than exiting, since exiting would only kill the worker. A sequential
# run exits with EXIT_CODE_STOP_ON_FAILURE, which main.sh's EXIT trap turns
# into the final summary. No-op when the flag is off.
##
# Sets _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT when a line of $1 is a real bash
# diagnostic: one carrying the source-and-line prefix as well as a known
# phrase. Split out so a text miss falls through to the exit-code check rather
# than returning from the caller.
##
function bashunit::runner::_scan_diagnostic_lines() {
local runtime_output=$1
# A phrase alone is not enough. The capture also carries bashunit's own
# rendering of the failure, and a test whose subject is error handling will
# legitimately quote one of these strings as data -- both used to be misread
# as runtime errors and reported twice, as Failed and as Error, for one cause.
# Requiring the prefix on the same line separates what the shell said from what
# we said about it; bashunit's own output never carries it.
local line
while IFS= read -r line; do
case "$line" in
*": line "[0-9]*": "*) ;;
*) continue ;;
esac

case "$line" in
*"command not found"* | *"unbound variable"* | *"permission denied"* | \
*"no such file or directory"* | *"syntax error"* | *"bad substitution"* | \
*"division by 0"* | *"bad file descriptor"* | \
*"illegal option"* | *"argument list too long"* | \
*"readonly variable"* | *"missing keyword"* | \
*"cannot execute binary file"* | *"invalid arithmetic operator"* | \
*"ambiguous redirect"* | *"integer expression expected"* | \
*"too many arguments"* | *"value too great"* | \
*"not a valid identifier"* | *"unexpected EOF"*)
# Extract from the whole capture, not the matched line: the message shape
# (leading source stripped, newlines removed) is pinned by
# tests/unit/runner/diagnostics_test.sh.
local runtime_error="${runtime_output#*: }"
_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="${runtime_error//$'\n'/}"
return
;;
esac
done <<EOF
$runtime_output
EOF
}

##
function bashunit::runner::halt_if_stop_on_failure() {
bashunit::env::is_stop_on_failure_enabled || return 0
Expand All @@ -37,6 +81,7 @@ function bashunit::runner::halt_if_stop_on_failure() {
# Arguments: $1 runtime_output
function bashunit::runner::detect_runtime_error() {
local runtime_output=$1
local exit_code=${2:-0}
_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT=""
_BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT=$runtime_output

Expand Down Expand Up @@ -98,44 +143,28 @@ $usage_after"
*"cannot execute binary file"* | *"invalid arithmetic operator"* | \
*"ambiguous redirect"* | *"integer expression expected"* | \
*"too many arguments"* | *"value too great"* | \
*"not a valid identifier"* | *"unexpected EOF"*) ;;
*) return ;;
*"not a valid identifier"* | *"unexpected EOF"*)
bashunit::runner::_scan_diagnostic_lines "$runtime_output"
if [ -n "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT" ]; then
return
fi
;;
esac

# A phrase alone is not enough. The capture also carries bashunit's own
# rendering of the failure, and a test whose subject is error handling will
# legitimately quote one of these strings as data -- both used to be misread
# as runtime errors and reported twice, as Failed and as Error, for one cause.
# Requiring the prefix on the same line separates what the shell said from what
# we said about it; bashunit's own output never carries it.
local line
while IFS= read -r line; do
case "$line" in
*": line "[0-9]*": "*) ;;
*) continue ;;
esac

case "$line" in
*"command not found"* | *"unbound variable"* | *"permission denied"* | \
*"no such file or directory"* | *"syntax error"* | *"bad substitution"* | \
*"division by 0"* | *"bad file descriptor"* | \
*"illegal option"* | *"argument list too long"* | \
*"readonly variable"* | *"missing keyword"* | \
*"cannot execute binary file"* | *"invalid arithmetic operator"* | \
*"ambiguous redirect"* | *"integer expression expected"* | \
*"too many arguments"* | *"value too great"* | \
*"not a valid identifier"* | *"unexpected EOF"*)
# Extract from the whole capture, not the matched line: the message shape
# (leading source stripped, newlines removed) is pinned by
# tests/unit/runner/diagnostics_test.sh.
local runtime_error="${runtime_output#*: }"
_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="${runtime_error//$'\n'/}"
return
;;
esac
done <<EOF
$runtime_output
EOF
# Last resort, and locale-independent. Everything above matches English text,
# but bash translates its diagnostics -- under es_ES a missing command reads
# "orden no encontrada" and matches nothing, so a genuine failure-to-run used
# to be reported as a plain assertion failure.
#
# These two codes carry the same fact without any text: the shell reserves 127
# for "could not find it" and 126 for "found it, could not run it". Consulted
# only after the text scan draws a blank, so English behaviour -- including the
# more specific message it produces -- is unchanged.
case "$exit_code" in
127) _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="command not found (exit code 127)" ;;
126) _BASHUNIT_RUNNER_RUNTIME_ERROR_OUT="not executable (exit code 126)" ;;
esac
}

##
Expand Down
8 changes: 6 additions & 2 deletions src/runner/exec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,14 @@ function bashunit::runner::run_test() {
fi

local attempt_runtime_output="${test_execution_result%%##ASSERTIONS_*}"
bashunit::runner::detect_runtime_error "$attempt_runtime_output"
# Counts first: detect_runtime_error consults the exit code when the output
# text is translated and matches nothing. extract_result_counts is a pure
# read, so moving it ahead commits nothing.
bashunit::runner::extract_result_counts "$test_execution_result"
bashunit::runner::detect_runtime_error "$attempt_runtime_output" \
"$_BASHUNIT_RUNNER_COUNTS_EXIT_CODE_OUT"
local attempt_runtime_error=$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT
local attempt_display_output=$_BASHUNIT_RUNNER_RUNTIME_OUTPUT_OUT
bashunit::runner::extract_result_counts "$test_execution_result"
# Mirror the commit-phase failure test exactly (runtime error, non-zero exit,
# or a failed assertion); snapshot/incomplete/skipped/risky are not failures.
if [ -z "$attempt_runtime_error" ] &&
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
Running ./tests/acceptance/fixtures/test_bashunit_when_exit_immediately_after_execution_error.sh
✗ Error: Error

command not found (exit code 127)
at ./tests/acceptance/fixtures/test_bashunit_when_exit_immediately_after_execution_error.sh:3

There was 1 failure:

|1) ./tests/acceptance/fixtures/test_bashunit_when_exit_immediately_after_execution_error.sh:3
|command not found (exit code 127)

Tests:  1 failed, 1 total
Assertions: 0 failed, 0 total
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/runner/diagnostics_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,37 @@ function test_classify_kill_signal_generic_signal() {
function test_classify_kill_signal_empty_for_normal_exit() {
assert_empty "$(bashunit::runner::classify_kill_signal 1)"
}

# bash translates its diagnostics, so the English phrase list matches nothing
# under a Spanish or Japanese locale and a genuine failure-to-run was reported
# as a plain assertion failure. The exit code carries the same information and
# is locale-independent: 127 is "could not find it", 126 "could not run it".
function test_detect_runtime_error_uses_the_exit_code_when_the_text_is_translated() {
bashunit::runner::detect_runtime_error \
"/tmp/x.sh: línea 1: foo: orden no encontrada" 127

assert_not_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT"
}

function test_detect_runtime_error_uses_the_exit_code_for_not_executable() {
bashunit::runner::detect_runtime_error \
"/tmp/x.sh: ligne 1: foo: Permission non accordée" 126

assert_not_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT"
}

# A failing assertion is not a runtime error, whatever the exit code says about
# the test function itself.
function test_detect_runtime_error_ignores_an_ordinary_failure_exit_code() {
bashunit::runner::detect_runtime_error "Expected 'a' but got 'b'" 1

assert_empty "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT"
}

# English text still wins, so the message stays as informative as before.
function test_detect_runtime_error_prefers_the_matched_text_over_the_exit_code() {
bashunit::runner::detect_runtime_error \
"/tmp/x.sh: line 1: foo: command not found" 127

assert_contains "command not found" "$_BASHUNIT_RUNNER_RUNTIME_ERROR_OUT"
}
Loading