testmark-bash

run.bash

## run.bash: the high road -- rules that make hunks into cases, and a runner that exercises each case and checks its output.
## Sourced by testmark.bash; the functions here assume they run inside `_testmark_call`.

# Rules, in the order made, as parallel arrays: rule i is glob i and function i.
_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=

## Sets _testmarktest_dir to the directory of script $1, if it's a file.
_testmarktest_init() {
	local top=$1
	[[ -f $top ]] || return 0
	[[ $top == */* ]] || top=./$top
	_testmarktest_dir=${top%/*}
	_testmarktest_dir=${_testmarktest_dir:-/}
	[[ $_testmarktest_dir == /* ]] || _testmarktest_dir=$PWD/$_testmarktest_dir
}

## Fails with a message unless $2 is a defined function; $1 is the public function asking, for the message.
_testmarktest_need_fn() {
	declare -F -- "$2" >/dev/null || {
		_testmark_err "$1: \"$2\" is not a defined function"
		return 1
	}
}

_testmarktest_rule_basic() {
	(($# == 2)) || {
		_testmark_err "usage: testmarktest_rule_basic GLOB FN"
		return 1
	}
	[[ -n $1 ]] || {
		_testmark_err "testmarktest_rule_basic: the glob is empty"
		return 1
	}
	_testmarktest_need_fn testmarktest_rule_basic "$2" || return 1
	_testmarktest_globs+=("$1")
	_testmarktest_fns+=("$2")
}

_testmarktest_provide() {
	(($# > 0)) || {
		_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+=("$@")
}

_testmarktest_run() {
	(($# > 0)) || {
		_testmark_err "usage: testmarktest_run DOC..."
		return 1
	}
	((${#_testmarktest_fns[@]} > 0)) || {
		_testmark_err "testmarktest_run: no rules; make them with testmarktest_rule_basic first"
		return 1
	}
	[[ -n $_testmarktest_dir ]] || {
		_testmark_err "testmarktest_run: no script file to find the docs beside; run it from a script"
		return 1
	}
	local -a matched=()
	local i doc failed=0
	for i in "${!_testmarktest_fns[@]}"; do matched[i]=0; done
	for doc; do _testmarktest_doc "$doc" || failed=1; done
	for i in "${!_testmarktest_fns[@]}"; do
		((matched[i] > 0)) || {
			_testmark_err "rule \"${_testmarktest_globs[i]}\" ${_testmarktest_fns[i]}: matched no hunk"
			failed=1
		}
	done
	return "$failed"
}

## Runs every case in doc $1, reports each, and writes the doc's regen patches.
## Counts each rule's triggers in the caller's `matched`.
_testmarktest_doc() {
	local doc=$1 path=$1 h=testmarktest_run
	[[ $path == /* ]] || path=$_testmarktest_dir/$path
	_testmark_parse "$h" "$path" || {
		printf 'FAIL  %s\n' "$doc"
		return 1
	}
	local -a names
	local name i failed=0 any=
	mapfile -t names < <(_testmark_names "$h")
	for name in "${names[@]}"; do
		for i in "${!_testmarktest_globs[@]}"; do
			# shellcheck disable=SC2053 # the glob is a pattern on purpose
			[[ $name == ${_testmarktest_globs[i]} ]] || continue
			matched[i]=$((matched[i] + 1)) any=1
			if _testmarktest_case "$h" "$name" "${_testmarktest_fns[i]}"; then
				printf 'PASS  %s: %s (%s)\n' "$doc" "$name" "${_testmarktest_fns[i]}"
			else
				printf 'FAIL  %s: %s (%s)\n' "$doc" "$name" "${_testmarktest_fns[i]}"
				failed=1
			fi
		done
	done
	[[ -n $any ]] || {
		_testmark_err "$doc: no hunk matches any rule"
		failed=1
	}
	_testmark_write "$h" || failed=1
	return "$failed"
}

## Exercises the case of trigger $2 in document $1 with function $3, and checks its output against the case's `output` hunk.
_testmarktest_case() {
	[[ $2 == */* ]] || {
		_testmark_hunk_err "$1" "$2" "has no \"/\", so it names no case"
		return 1
	}
	local out=${2%/*}/output stream st
	_testmark_hunk "$1" "$out" || return 1
	# The exit status follows the output after a NUL, which bash can't hold; so a second NUL means the output held one.
	{
		IFS= read -r -d '' stream
		if IFS= read -r -d '' st; then
			_testmark_hunk_err "$1" "$2" "output contains a NUL byte"
			return 1
		fi
	} < <(
		_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'
	fi
	_testmark_check "$1" "$out" < <(printf '%s' "$stream")
}

## Runs function $3 in a fresh bash, in the script's directory, with the body of hunk $2 of document $1 on stdin.
## stdout and stderr both go to stdout.  Runs in a process substitution, so its `cd` and `unset` change nothing outside.
_testmarktest_exec() {
	# _testmark_call's `local LC_ALL=C` would reach the case if the caller exported LC_ALL; unsetting it uncovers the caller's own.
	# BASH_ENV would be sourced by the case's bash, and a case should start clean.
	unset -v LC_ALL BASH_ENV
	cd "$_testmarktest_dir" 2>&1 || return
	"$BASH" <(_testmarktest_script "$3") < <(_testmark_body "$1" "$2") 2>&1
}

## 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 fd=$_testmarktest_fdbase
	printf 'set -euo pipefail\n'
	# 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 /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
}

lib/testmark/run.bash · 7.1 KiB · 3b20fba208