# run-test.sh: runs a set of test cases # (C) 2016-2023 magicant # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # This script expects two operands. # The first is the pathname to the testee, the shell that is to be tested. # The second is the pathname to the test file that defines test cases. # The result is output to a result file whose name is given by replacing the # extension of the test file with ".trs". # If any test case fails, it is also reported to the standard error. # If the -r option is specified, intermediate files are not removed. # If the -v option is specified, the testee is tested by Valgrind. # The exit status is zero unless a critical error occurs. Failure of test cases # does not cause the script to return non-zero. set -Ceu umask u+rwx ##### Some utility functions and aliases eprintf() { printf "$@" >&2 } # $1 = pathname absolute() case "$1" in (/*) printf '%s\n' "$1";; (*) printf '%s/%s' "${PWD%/}" "$1";; esac ##### Script startup # The -c option is not POSIXly-portable, but many shells support it. ulimit -c 0 2>/dev/null || : exec &- 4>&- 5>&- # ensure correctness of $PWD cd -L . remove_work_dir="true" use_valgrind="false" while getopts rv opt; do case $opt in (r) remove_work_dir="false";; (v) use_valgrind="true";; (*) exit 64 # sysexits.h EX_USAGE esac done shift "$((OPTIND-1))" testee="${1:?testee not specified}" test_file="${2:?test file not specified}" log_file="${3:?log file not specified}" testee="$(absolute "$(command -v -- "$testee")")" log_file="$(absolute "$log_file")" exec >|"$log_file" export LC_CTYPE="${LC_ALL-${LC_CTYPE-$LANG}}" export LANG=C export YASH_LOADPATH= # ignore default yashrc unset -v CDPATH COLUMNS COMMAND_NOT_FOUND_HANDLER DIRSTACK ECHO_STYLE ENV unset -v FCEDIT HANDLED HISTFILE HISTRMDUP HISTSIZE HOME IFS LC_ALL unset -v LC_COLLATE LC_MESSAGES LC_MONETARY LC_NUMERIC LC_TIME LINES MAIL unset -v MAILCHECK MAILPATH NLSPATH OLDPWD PROMPT_COMMAND unset -v PS1 PS1R PS1S PS2 PS2R PS2S PS3 PS3R PS3S PS4 PS4R PS4S unset -v RANDOM TERM YASH_AFTER_CD YASH_LE_TIMEOUT YASH_VERSION unset -v A B C D E F G H I J K L M N O P Q R S T U V W X Y Z _ unset -v a b c d e f g h i j k l m n o p q r s t u v w x y z unset -v posix skip # If the current shell does not support $LINENO, assign a dummy value to it and # let the testcase function handle it. lineno1=${LINENO-} lineno2=${LINENO-} [ "$lineno1" != "$lineno2" ] || LINENO= ##### Prepare temporary directory work_dir="${TMPDIR:-.}/tmp.$$" rm_work_dir() if "$remove_work_dir"; then if [ -d "$work_dir" ]; then chmod -R a+rX "$work_dir"; fi rm -fr "$work_dir" fi trap rm_work_dir EXIT trap 'rm_work_dir; trap - INT; kill -INT $$' INT trap 'rm_work_dir; trap - TERM; kill -TERM $$' TERM trap 'rm_work_dir; trap - QUIT; kill -QUIT $$' QUIT mkdir "$work_dir" ##### Some more utilities { if diff -U 10000 /dev/null /dev/null; then diff_opt='-U 10000' elif diff -C 10000 /dev/null /dev/null; then diff_opt='-C 10000' else diff_opt='' fi } >/dev/null 2>&1 setup_script="" # Add a setup script that is run before each test case # If the first argument is "-" or omitted, the script is read from stdin. # If the first argument is "-d", the default utility functions are added. # Otherwise, the first argument is added as the script. setup() { case "${1--}" in (-) setup "$(cat)" ;; (-d) setup <<\END _empty= _sp=' ' _tab=' ' _nl=' ' echoraw() { printf '%s\n' "$*" } bracket() { if [ $# -gt 0 ]; then printf '[%s]' "$@"; fi echo } END ;; (*) setup_script="$setup_script $1" ;; esac } # Invokes the testee. # If the "posix" variable is defined non-empty, the testee is invoked as "sh". # If the "use_valgrind" variable is true, Valgrind is used to run the testee, # in which case the testee will ignore argv[0]. testee() ( exec_testee "$@" ) exec_testee() { if [ "${posix:+set}" = set ]; then testee="$testee_sh" export TESTEE="$testee" fi if [ "${test_lineno:+set}" = set ]; then export TEST_NO="$test_lineno" fi if ! "$use_valgrind"; then exec "$testee" "$@" else test -r "$abs_suppressions" || abs_suppressions= exec valgrind --leak-check=full --vgdb=no --log-fd=17 \ ${abs_suppressions:+--suppressions="$abs_suppressions"} \ --gen-suppressions=all \ "$testee" "$@" \ 17>>"${valgrind_file-0.valgrind}" fi } # The test case runner. # # Contents of file descriptor 3 are passed to the standard input of a newly # invoked testee using a temporary file. # Contents of file descriptor 4 and 5 are compared to the actual output from # the standard output and error of the testee, respectively, if those file # descriptors are open. If they differ from the expected, the test case fails. # # The first argument is treated as the line number where the test case appears # in the test file. As remaining arguments, options and operands may follow. # # If the "-d" option is specified, the test case fails unless the actual output # to the standard error is non-empty. File descriptor 5 is ignored. # # If the "-e " option is specified, the exit status of # the testee is also checked. If the actual exit status differs from the # expected, the test case fails. If is "n", the expected # is any non-zero exit status. If is a signal name (w/o # the SIG-prefix), the testee is expected to be killed by the signal. # # The first operand is used as the name of the test case. # The remaining operands are passed as arguments to the testee. # # If the "skip" variable is defined non-empty, the test case is skipped. testcase() { # Shells without $LINENO support give an empty $1, in which case we fall # back to the line count of the current log file. test_lineno="${1:-$(wc -l <"$log_file")}" shift 1 OPTIND=1 diagnostic_required="false" expected_exit_status="" while getopts de: opt; do case $opt in (d) diagnostic_required="true";; (e) expected_exit_status="$OPTARG";; (*) return 64 # sysexits.h EX_USAGE esac done shift "$((OPTIND-1))" test_case_name="${1:?unnamed test case \($test_file:$test_lineno\)}" shift 1 log_stdout() { printf '%%%%%% %s: %s:%d: %s\n' \ "$1" "$test_file" "$test_lineno" "$test_case_name" } in_file="$test_lineno.in" out_file="$test_lineno.out" err_file="$test_lineno.err" valgrind_file="$test_lineno.valgrind" # prepare input file { if [ "$setup_script" ]; then printf '%s\n' "$setup_script" fi cat <&3 } >"$in_file" chmod u+r "$in_file" if [ "${skip-}" ]; then log_stdout SKIPPED echo return fi if [ -e "$out_file" ]; then printf 'Output file %s already exists.\n' "$out_file" return 1 fi if [ -e "$err_file" ]; then printf 'Output file %s already exists.\n' "$err_file" return 1 fi # run the testee log_stdout START set +e # Output files are opened in append mode to ensure write atomicity. # To ignore a description message printed by some shells in case the testee # is terminated by a signal, "$err_file" must be opened in a subshell. ( exec_testee "$@" <"$in_file" >>"$out_file" 2>>"$err_file" 3>&- 4>&- 5>&- ) 2>/dev/null actual_exit_status="$?" set -e chmod u+r "$out_file" "$err_file" failed="false" # check exit status exit_status_fail() { failed="true" eprintf '%s:%d: %s: exit status mismatch\n' \ "$test_file" "$test_lineno" "$test_case_name" } case "$expected_exit_status" in ('') ;; (n) printf '%% exit status: expected=non-zero actual=%d\n\n' \ "$actual_exit_status" if [ "$actual_exit_status" -eq 0 ]; then exit_status_fail fi ;; ([[:alpha:]]*) printf '%% exit status: expected=%s ' "$expected_exit_status" if [ "$actual_exit_status" -le 128 ] || ! actual_signal="$(kill -l "$actual_exit_status" \ 2>/dev/null)"; then printf 'actual=%d\n\n' "$actual_exit_status" exit_status_fail else printf 'actual=%d(%s)\n\n' \ "$actual_exit_status" "$actual_signal" if [ "$actual_signal" != "$expected_exit_status" ]; then exit_status_fail fi fi ;; (*) printf '%% exit status: expected=%d actual=%d\n\n' \ "$expected_exit_status" "$actual_exit_status" if [ "$actual_exit_status" -ne "$expected_exit_status" ]; then exit_status_fail fi ;; esac # check standard output if { <&4; } 2>/dev/null; then printf '%% standard output diff:\n' if ! diff $diff_opt - "$out_file" <&4; then failed="true" eprintf '%s:%d: %s: standard output mismatch\n' \ "$test_file" "$test_lineno" "$test_case_name" fi echo fi # check standard error if "$diagnostic_required"; then printf '%% standard error (expecting non-empty output):\n' cat "$err_file" if ! [ -s "$err_file" ]; then failed="true" eprintf '%s:%d: %s: standard error mismatch\n' \ "$test_file" "$test_lineno" "$test_case_name" fi echo elif { <&5; } 2>/dev/null; then printf '%% standard error diff:\n' if ! diff $diff_opt - "$err_file" <&5; then failed="true" eprintf '%s:%d: %s: standard error mismatch\n' \ "$test_file" "$test_lineno" "$test_case_name" fi echo fi # check Valgrind results if [ -f "$valgrind_file" ]; then chmod a+r "$valgrind_file" printf '%% Valgrind log:\n' cat "$valgrind_file" echo if grep -q 'valgrind: fatal error:' "$valgrind_file"; then # There was an error in Valgrind. Treat this test case as skipped. log_stdout SKIPPED echo return fi if grep 'ERROR SUMMARY:' "$valgrind_file" | grep -qv ' 0 errors'; then failed="true" eprintf '%s:%d: %s: Valgrind detected error\n' \ "$test_file" "$test_lineno" "$test_case_name" fi fi if "$failed"; then log_stdout FAILED else log_stdout PASSED fi echo } alias test_x='testcase "$LINENO" 3<<\__IN__ 4<&- 5<&-' alias test_o='testcase "$LINENO" 3<<\__IN__ 4<<\__OUT__ 5<&-' alias test_O='testcase "$LINENO" 3<<\__IN__ 4