testmark-bash

cases report bash's errors as the function's name, not an fd path and phantom line.

commit 3b20fba208c97fb7a090822ca369bc69220ab80e · parent 93b1f15773 · browse this tree
Ximianth, 2026-10-25 09:50:07 UTC

Bash's runtime errors inside a case's functions used to surface as
`/dev/fd/61: line 4: ...`: the fd number shifts with whatever fds the
environment holds open (so committed fixtures broke under a different runner),
and the line number counts declare -f's rendering, text that exists in no file.

Each function is now sourced from a pinned fd (10 upward: explicit redirects
rebind held fds, so inheritance can't shift them), and _testmarktest_relabel
trades the `/dev/fd/F: line N: ` prefix for the function's own name.
The line number is dropped on purpose rather than translated:
any number we could print refers to text the user never wrote.
TESTMARK_RAW_ERRORS, set to any value, keeps bash's text verbatim.

The held-open-fds test fails against the previous code; the raw-mode test
pins the exact bytes the relabeling consumes.
diff --git a/lib/testmark.bash b/lib/testmark.bash
index 4d62194..2fd5523 100644
--- a/lib/testmark.bash
+++ b/lib/testmark.bash
@@ -106,6 +106,10 @@ testmark_check() { _testmark_call _testmark_check "$@"; }
 ##
 ## FN runs under `set -euo pipefail`.  It sees exported variables (except BASH_ENV, so its bash starts clean), but no other variables, and no functions but itself and those given to testmarktest_provide.
 ##
+## Bash's own runtime error messages from inside these functions (a command not found, an unbound variable) are relabeled in the output:
+## `FN: message`, with no path and no line number, so the text is the same wherever the test runs.
+## Set TESTMARK_RAW_ERRORS, to any value, to keep bash's text verbatim instead.
+##
 ## - @param $1 GLOB: a bash pattern, matched against whole hunk names; `*` matches `/` too.
 ## - @param $2 FN: the name of a defined function.
 testmarktest_rule_basic() { _testmark_call _testmarktest_rule_basic "$@"; }
diff --git a/lib/testmark/run.bash b/lib/testmark/run.bash
index ff22f6d..5b0997a 100644
--- a/lib/testmark/run.bash
+++ b/lib/testmark/run.bash
@@ -6,6 +6,9 @@ _testmarktest_globs=()
 _testmarktest_fns=()
 # Functions sent into every case, besides the rule's own.
 _testmarktest_provided=()
+# A case's script sources function i from pinned fd (this base + i): the case's own function, then the provided ones, in order.
+# _testmarktest_script assigns the pins; _testmarktest_relabel turns them back into function names.
+_testmarktest_fdbase=10
 # The directory of the script being run, absolute; empty if bash isn't running a script file.
 # Set when the library is sourced, so a later `cd` can't move it.
 _testmarktest_dir=
@@ -47,6 +50,12 @@ _testmarktest_provide() {
 		_testmark_err "usage: testmarktest_provide FN..."
 		return 1
 	}
+	# The pins count up from _testmarktest_fdbase and must stay clear of the fds bash gives
+	# process substitutions (63, counting down), so a case holds at most 50 functions.
+	((${#_testmarktest_provided[@]} + $# + 1 <= 50)) || {
+		_testmark_err "testmarktest_provide: a case can hold at most 50 functions"
+		return 1
+	}
 	local fn
 	for fn; do _testmarktest_need_fn testmarktest_provide "$fn" || return 1; done
 	_testmarktest_provided+=("$@")
@@ -130,6 +139,7 @@ _testmarktest_case() {
 		_testmarktest_exec "$1" "$2" "$3"
 		printf '\0%s' "$?"
 	)
+	[[ -n ${TESTMARK_RAW_ERRORS+set} ]] || _testmarktest_relabel stream "$3"
 	if ((st != 0)); then
 		[[ -z $stream || $stream == *$'\n' ]] || stream+=$'\n'
 		stream+="exit status: $st"$'\n'
@@ -150,12 +160,42 @@ _testmarktest_exec() {
 ## Prints the script a case runs: strict mode, the functions it may call, and a call to function $1.
 ## This is the one place a case's script is built.
 _testmarktest_script() {
-	local fn
+	local fn fd=$_testmarktest_fdbase
 	printf 'set -euo pipefail\n'
-	# Each function is sourced from its own process substitution, so bash's errors inside one, which land in fixtures,
-	# give line numbers within that function, whatever else this script holds.
+	# Each function is sourced from its own pinned fd, so bash's errors inside one name a source the
+	# harness chose, whatever fds the environment holds open; _testmarktest_relabel maps it back.
 	for fn in "$1" "${_testmarktest_provided[@]}"; do
-		printf 'source <(printf %%s %q)\n' "$(declare -f -- "$fn")"
+		printf 'source /dev/fd/%d %d< <(printf %%s %q)\n' "$fd" "$fd" "$(declare -f -- "$fn")"
+		fd=$((fd + 1))
 	done
 	printf '%q\n' "$1"
 }
+
+## Relabels bash's own runtime error messages in a case's output, held by the variable named $1:
+## a line beginning `/dev/fd/F: line N: `, where F is the pin of case function $2 or of a provided function,
+## trades that prefix for the function's name, becoming `FN: ` and the rest.
+## The line number is dropped on purpose: it counts lines of `declare -f` output, text that exists in no file.
+_testmarktest_relabel() {
+	local -n _tmr_stream=$1
+	local -a fns=("$2" "${_testmarktest_provided[@]}")
+	local out='' rest=$_tmr_stream line len body num i
+	while :; do
+		line=${rest%%$'\n'*}
+		len=${#line}
+		if [[ $line == /dev/fd/* ]]; then
+			for i in "${!fns[@]}"; do
+				body="/dev/fd/$((_testmarktest_fdbase + i)): line "
+				[[ $line == "$body"* ]] || continue
+				body=${line#"$body"}
+				num=${body%%:*}
+				[[ -n $num && $num != *[!0-9]* && $body == "$num: "* ]] && line="${fns[i]}: ${body#"$num": }"
+				break
+			done
+		fi
+		out+=$line
+		((len < ${#rest})) || break
+		out+=$'\n'
+		rest=${rest:len+1}
+	done
+	_tmr_stream=$out
+}
diff --git a/tests/test-run.sh b/tests/test-run.sh
index 3cfd6ab..58b5cd9 100755
--- a/tests/test-run.sh
+++ b/tests/test-run.sh
@@ -7,7 +7,7 @@ cd "$(dirname "$0")"
 # shellcheck source=tests/lib.sh
 source ./lib.sh
 
-unset TESTMARK_REGEN
+unset TESTMARK_REGEN TESTMARK_RAW_ERRORS
 mkdir t
 
 ## Writes t/doc.md and t/test.sh, runs the script from $work, and checks what it printed and its status.
@@ -149,7 +149,7 @@ testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_p)
 
 --- status 0"
 
-run_case "nounset is on in a case" "$(one $'/dev/fd/61: line 3: nope: unbound variable\nexit status: 1\n')" '
+run_case "nounset is on in a case; bash's error is relabeled with the function's name" "$(one $'exercise_u: nope: unbound variable\nexit status: 1\n')" '
 exercise_u() { echo "$nope"; }
 testmarktest_rule_basic "*/in" exercise_u
 testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_u)
@@ -157,6 +157,23 @@ testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_u)
 
 --- status 0"
 
+run_case "held-open fds don't change a case's error text" "$(one $'exercise_u: nope: unbound variable\nexit status: 1\n')" '
+exec 10>/dev/null 61>/dev/null 62>/dev/null 63>/dev/null
+exercise_u() { echo "$nope"; }
+testmarktest_rule_basic "*/in" exercise_u
+testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_u)
+--- stderr
+
+--- status 0"
+
+run_case "TESTMARK_RAW_ERRORS keeps bash's own text" "$(one $'/dev/fd/10: line 3: nope: unbound variable\nexit status: 1\n')" '
+exercise_u() { echo "$nope"; }
+testmarktest_rule_basic "*/in" exercise_u
+TESTMARK_RAW_ERRORS=1 testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_u)
+--- stderr
+
+--- status 0"
+
 run_case "the caller's set -e doesn't stop a run partway" '[testmark]:# (a/in)
 ```
 ```
@@ -224,7 +241,7 @@ testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_lc)
 
 --- status 0"
 
-run_case "other functions don't cross, the library included" "$(one $'/dev/fd/61: line 4: helper: command not found\nexit status: 127\n')" '
+run_case "other functions don't cross, the library included" "$(one $'exercise_h: helper: command not found\nexit status: 127\n')" '
 helper() { echo helped; }
 exercise_h() { declare -F | grep testmark || :; helper; }
 testmarktest_rule_basic "*/in" exercise_h
@@ -233,7 +250,7 @@ testmarktest_run doc.md' "PASS  doc.md: a/in (exercise_h)
 
 --- status 0"
 
-run_case "an error in a provided function counts lines from that function's start" "$(one $'/dev/fd/61: line 3: nope_cmd: command not found\nexit status: 127\n')" '
+run_case "an error in a provided function is labeled with that function's name" "$(one $'helper: nope_cmd: command not found\nexit status: 127\n')" '
 helper() {
 	nope_cmd
 }