#!/bin/bash # # Serval Project testing framework for Bash shell # Copyright 2012 Serval Project, Inc. # # 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, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # This file is sourced by all testing scripts. A typical test script looks # like this: # # #!/bin/bash # source testframework.sh # setup() { # export BLAH_CONFIG=$TFWTMP/blah.conf # echo "username=$LOGNAME" >$BLAH_CONFIG # } # teardown() { # # $TFWTMP is always removed after every test, so no need to # # remove blah.conf ourselves. # } # doc_feature1='Feature one works' # test_feature1() { # execute programUnderTest --feature1 arg1 arg2 # assertExitStatus '==' 0 # assertRealTime --message='ran in under half a second' '<=' 0.5 # assertStdoutIs "" # assertStderrIs "" # tfw_cat arg1 # } # doc_feature2='Feature two fails with status 1' # setup_feature2() { # # Overrides setup(), so we have to call it ourselves explicitly # # here if we still want it. # setup # echo "option=specialValue" >>$BLAH_CONFIG # } # test_feature2() { # execute programUnderTest --feature2 arg1 arg2 # assertExitStatus '==' 1 # assertStdoutIs -e "Response:\tok\n" # assertStderrGrep "^ERROR: missing arg3$" # } # runTests "$@" AWK=awk SED=sed GREP=grep TSFMT='+%Y-%m-%d %H:%M:%S' SYSTYPE=`uname -s` if [ $SYSTYPE = "SunOS" ]; then AWK=gawk SED=gsed GREP=ggrep fi if [ $SYSTYPE = "Linux" ]; then # Get nanosecond resolution TSFMT='+%Y-%m-%d %H:%M:%S.%N' fi usage() { echo -n "\ Usage: ${0##*/} [options] [--] Options: -t, --trace Enable shell "set -x" tracing during tests, output to test log -v, --verbose Send test log to output during execution -j, --jobs Run all tests in parallel (by default runs as --jobs=1) --jobs=N Run tests in parallel, at most N at a time -E, --stop-on-error Do not execute any tests after an ERROR occurs -F, --stop-on-failure Do not execute any tests after a FAIL occurs --filter=PREFIX Only execute tests whose names start with PREFIX --filter=N Only execute test number N --filter=M-N Only execute tests with numbers in range M-N inclusive --filter=-N Only execute tests with numbers <= N --filter=N- Only execute tests with numbers >= N --filter=M,N,... Only execute tests with number M or N or ... " } # Internal utility for setting shopt variables and restoring their original # value: # local oo # _tfw_shopt oo -s extglob -u extdebug # ... # _tfw_shopt_restore oo _tfw_shopt() { local _var="$1" shift local op=s local restore= while [ $# -ne 0 ] do case "$1" in -s) op=s;; -u) op=u;; *) local opt="$1" restore="${restore:+$restore; }shopt -$(shopt -q $opt && echo s || echo u) $opt" shopt -$op $opt ;; esac shift done eval $_var='"$restore"' } _tfw_shopt_restore() { local _var="$1" [ -n "${!_var}" ] && eval "${!_var}" } declare -a _tfw_included_tests=() declare -a _tfw_test_names=() declare -a _tfw_test_sourcefiles=() declare -a _tfw_job_pgids=() declare -a _tfw_forked_pids=() # The rest of this file is parsed for extended glob patterns. _tfw_shopt _tfw_orig_shopt -s extglob includeTests() { local arg for arg; do local path case "$arg" in /*) path="$(abspath "$arg")";; *) path="$(abspath "$_tfw_script_dir/arg")";; esac local n=$((${#BASH_LINENO[*]} - 1)) while [ $n -gt 0 -a ${BASH_LINENO[$n]} -eq 0 ]; do let n=n-1 done _tfw_included_tests+=("${BASH_LINENO[$n]} $arg") done } runTests() { [ -n "${_tfw_recursive_source}" ] && return 0 _tfw_stdout=1 _tfw_stderr=2 _tfw_log_fd= _tfw_checkBashVersion export PATH="$(_tfw_abspath "${BASH_SOURCE%/*}"):$PATH" _tfw_checkTerminfo _tfw_checkCommandInPATH tfw_createfile _tfw_invoking_script=$(abspath "${BASH_SOURCE[1]}") _tfw_script_name="${_tfw_invoking_script##*/}" _tfw_script_dir="${_tfw_invoking_script%/*}" _tfw_cwd=$(abspath "$PWD") _tfw_tmpdir="$(_tfw_abspath -P "${TFW_TMPDIR:-${TMPDIR:-/tmp}}")" _tfw_tmpmain="$_tfw_tmpdir/_tfw-$$" _tfw_njobs=1 _tfw_log_noise=true _tfw_assert_noise=true _tfw_logdir_script="${TFW_LOGDIR:-$_tfw_cwd/testlog}/$_tfw_script_name" _tfw_list=false _tfw_trace=false _tfw_verbose=false _tfw_stop_on_error=false _tfw_stop_on_failure=false _tfw_default_timeout=60 local allargs="$*" local -a filters=() local oo _tfw_shopt oo -s extglob while [ $# -ne 0 ]; do case "$1" in --help) usage; exit 0;; -l|--list) _tfw_list=true;; -t|--trace) _tfw_trace=true;; -v|--verbose) _tfw_verbose=true;; --filter=*) filters+=("${1#*=}");; -j|--jobs) _tfw_njobs=0;; --jobs=+([0-9])) _tfw_njobs="${1#*=}";; --jobs=*) _tfw_fatal "invalid option: $1";; -E|--stop-on-error) _tfw_stop_on_error=true;; -F|--stop-on-failure) _tfw_stop_on_failure=true;; --) shift; break;; --*) _tfw_fatal "unsupported option: $1";; *) _tfw_fatal "spurious argument: $1";; esac shift done _tfw_shopt_restore oo if $_tfw_verbose && [ $_tfw_njobs -ne 1 ]; then _tfw_fatal "--verbose is incompatible with --jobs=$_tfw_njobs" fi # Enumerate all the test cases. _tfw_list_tests # If we are only asked to list them, then do so and finish. if $_tfw_list; then local testNumber for ((testNumber = 1; testNumber <= ${#_tfw_test_names[*]}; ++testNumber)); do local testSourceFile="${_tfw_test_sourcefiles[$(($testNumber - 1))]}" local testName="${_tfw_test_names[$(($testNumber - 1))]}" if _tfw_filter_predicate "$testNumber" "$testName" "${filters[@]}"; then echo "$testNumber $testName ${testSourceFile#$_tfw_script_dir/}" fi done return 0 fi # Create base temporary working directory. trap '_tfw_status=$?; _tfw_killtests 2>/dev/null; rm -rf "$_tfw_tmpmain"; trap - EXIT; exit $_tfw_status' EXIT SIGHUP SIGINT SIGTERM rm -rf "$_tfw_tmpmain" mkdir -p "$_tfw_tmpmain" || return $? # Create an empty results directory. _tfw_results_dir="$_tfw_tmpmain/results" mkdir "$_tfw_results_dir" || return $? # Create an empty log directory. mkdir -p "$_tfw_logdir_script" || return $? rm -r -f "$_tfw_logdir_script"/* # Enable job control. set -m # Iterate through all test cases, starting a new test whenever the number of # running tests is less than the job limit. _tfw_testcount=0 _tfw_passcount=0 _tfw_failcount=0 _tfw_errorcount=0 _tfw_fatalcount=0 _tfw_job_pgids=() _tfw_job_pgids[0]= _tfw_test_number_watermark=0 local testNumber local testPosition=0 for ((testNumber = 1; testNumber <= ${#_tfw_test_names[*]}; ++testNumber)); do local testSourceFile="${_tfw_test_sourcefiles[$(($testNumber - 1))]}" local testName="${_tfw_test_names[$(($testNumber - 1))]}" local scriptName="${testSourceFile#$_tfw_script_dir/}" _tfw_filter_predicate "$testNumber" "$testName" "${filters[@]}" || continue let ++testPosition let ++_tfw_testcount # Wait for any existing child process to finish. while [ $_tfw_njobs -ne 0 -a $(_tfw_count_running_jobs) -ge $_tfw_njobs ]; do _tfw_wait_job_finish _tfw_harvest_jobs done [ $_tfw_fatalcount -ne 0 ] && break $_tfw_stop_on_error && [ $_tfw_errorcount -ne 0 ] && break $_tfw_stop_on_failure && [ $_tfw_failcount -ne 0 ] && break # Start the next test in a child process. _tfw_echo_progress $testPosition $testNumber "$testSourceFile" $testName if $_tfw_verbose || [ $_tfw_njobs -ne 1 ]; then echo fi echo "$testPosition $testNumber $testName" NONE "$testSourceFile" >"$_tfw_results_dir/$testNumber" ( # The directory where this test's log.txt and other artifacts are # deposited. _tfw_logdir_test="$_tfw_logdir_script/$testNumber.$testName" mkdir "$_tfw_logdir_test" || _tfw_fatalexit # Pick a unique decimal number that must not coincide with other tests # being run concurrently, _including tests being run in other test # scripts by other users on the same host_. We cannot simply use # $testNumber. The subshell process ID is ideal. We don't use # $BASHPID because MacOS only has Bash-3.2, and $BASHPID was introduced # in Bash-4. _tfw_unique=$($BASH -c 'echo $PPID') # All files created by this test belong inside a temporary directory. # The path name must be kept short because it is used to construct # named socket paths, which have a limited length. _tfw_tmp=$_tfw_tmpdir/_tfw-$_tfw_unique trap '_tfw_status=$?; rm -rf "$_tfw_tmp"; exit $_tfw_status' EXIT SIGHUP SIGINT SIGTERM mkdir $_tfw_tmp || _tfw_fatalexit local start_time=$(_tfw_timestamp) local finish_time=unknown ( _tfw_result=FATAL _tfw_stdout=5 _tfw_stderr=5 _tfw_log_fd=6 BASH_XTRACEFD=7 # Disable job control. set +m # If the test is from a different source script than the one # invoking us, then source that file to pull in all the test # definitions. if [ "$testSourceFile" != "$_tfw_invoking_script" ]; then _tfw_recursive_source=true source "$testSourceFile" unset _tfw_recursive_source fi declare -f test_$testName >/dev/null || _tfw_fatal "test_$testName not defined" # Where per-forked-process files get stored -- see fork(). _tfw_process_tmp="$_tfw_tmp" # Environment variables and temporary directories that test cases # depend upon. export COLUMNS=80 # for ls(1) multi-column output export TFWLOG="$_tfw_logdir_test" export TFWUNIQUE=$_tfw_unique export TFWVAR="$_tfw_tmp/var" mkdir $TFWVAR || _tfw_fatalexit export TFWTMP="$_tfw_tmp/tmp" mkdir $TFWTMP || _tfw_fatalexit cd $TFWTMP || _tfw_fatalexit if $_tfw_verbose; then # Find the PID of the current subshell process. Cannot use $BASHPID # because MacOS only has Bash-3.2, and $BASHPID was introduced in Bash-4. local mypid=$($BASH -c 'echo $PPID') # These tail processes will die when the current subshell exits. tail --pid=$mypid --follow $_tfw_tmp/log.stdout >&$_tfw_stdout 2>/dev/null & tail --pid=$mypid --follow $_tfw_tmp/log.stderr >&$_tfw_stderr 2>/dev/null & fi # Execute the test case. _tfw_phase=setup _tfw_result=ERROR _tfw_status= trap "_tfw_status=\$?; _tfw_finalise $testName; _tfw_teardown $testName; _tfw_exit" EXIT SIGHUP SIGINT SIGTERM _tfw_setup $testName _tfw_result=FAIL _tfw_phase=testcase tfw_log "# CALL test_$testName()" $_tfw_trace && set -x test_$testName set +x _tfw_result=PASS exit 0 ) <&- 5>&1 5>&2 6>"$_tfw_tmp/log.stdout" 1>&6 2>"$_tfw_tmp/log.stderr" 7>"$_tfw_tmp/log.xtrace" local stat=$? finish_time=$(_tfw_timestamp) local result=FATAL case $stat in 254) result=ERROR;; 1) result=FAIL;; 0) result=PASS;; esac echo "$testPosition $testNumber $testName $result $testSourceFile" >"$_tfw_results_dir/$testNumber" { echo "Name: $testName ($scriptName)" echo "Result: $result" echo "Started: $start_time" echo "Finished: $finish_time" echo '++++++++++ log.stdout ++++++++++' cat $_tfw_tmp/log.stdout echo '++++++++++' echo '++++++++++ log.stderr ++++++++++' cat $_tfw_tmp/log.stderr echo '++++++++++' if $_tfw_trace; then echo '++++++++++ log.xtrace ++++++++++' cat $_tfw_tmp/log.xtrace echo '++++++++++' fi } >"$_tfw_logdir_test/log.txt" mv "$_tfw_logdir_test" "$_tfw_logdir_test.$result" exit 0 ) /dev/null | $SED -n -e '1s/^\[\([0-9]\{1,\}\)\].*/\1/p') if [ -n "${_tfw_job_pgids[$job]}" ]; then _tfw_harvest_job $job fi _tfw_job_pgids[$job]=$(jobs -p %$job 2>/dev/null) ln -f -s "$_tfw_results_dir/$testNumber" "$_tfw_results_dir/job-$job" done # Wait for all child processes to finish. while _tfw_any_running_jobs; do _tfw_wait_job_finish _tfw_harvest_jobs done # Clean up working directory. rm -rf "$_tfw_tmpmain" trap - EXIT SIGHUP SIGINT SIGTERM # Echo result summary and exit with success if no failures or errors. s=$([ $_tfw_testcount -eq 1 ] || echo s) echo "$_tfw_testcount test$s, $_tfw_passcount pass, $_tfw_failcount fail, $_tfw_errorcount error" [ $_tfw_fatalcount -eq 0 -a $_tfw_failcount -eq 0 -a $_tfw_errorcount -eq 0 ] } _tfw_killtests() { if [ $_tfw_njobs -eq 1 ]; then echo " killing..." else echo -n -e "\r\rKilling tests..." fi trap '' SIGHUP SIGINT SIGTERM local pgid for pgid in $(jobs -p); do kill -TERM -$pgid done wait } _tfw_count_running_jobs() { local count=0 local job for ((job = 1; job < ${#_tfw_job_pgids[*]}; ++job)); do [ -n "${_tfw_job_pgids[$job]}" ] && let count=count+1 done echo $count } _tfw_any_running_jobs() { local job for ((job = 1; job < ${#_tfw_job_pgids[*]}; ++job)); do [ -n "${_tfw_job_pgids[$job]}" ] && return 0 done return 1 } _tfw_wait_job_finish() { if [ $_tfw_njobs -eq 1 ]; then wait >/dev/null 2>/dev/null else # This is the only way known to get the effect of a 'wait' builtin that # will return when _any_ child dies (or after a one-second timeout). set -m sleep 1 & local spid=$! trap "kill -TERM $spid 2>/dev/null" SIGCHLD wait $spid >/dev/null 2>/dev/null trap - SIGCHLD fi } _tfw_harvest_jobs() { local job for ((job = 1; job < ${#_tfw_job_pgids[*]}; ++job)); do if [ -n "${_tfw_job_pgids[$job]}" ]; then jobs %$job >/dev/null 2>/dev/null || _tfw_harvest_job $job fi done } _tfw_harvest_job() { local job="$1" # Kill any residual processes from the test case. local pgid=${_tfw_job_pgids[$job]} [ -n "$pgid" ] && kill -TERM -$pgid 2>/dev/null _tfw_job_pgids[$job]= # Report the test script outcome. if [ -s "$_tfw_results_dir/job-$job" ]; then local testPosition local testNumber local testName local result local testSourceFile _tfw_unpack_words "$(<"$_tfw_results_dir/job-$job")" testPosition testNumber testName result testSourceFile case "$result" in ERROR) let _tfw_errorcount=_tfw_errorcount+1 ;; PASS) let _tfw_passcount=_tfw_passcount+1 ;; FAIL) let _tfw_failcount=_tfw_failcount+1 ;; *) result=FATAL let _tfw_fatalcount=_tfw_fatalcount+1 ;; esac local lines if ! $_tfw_verbose && [ $_tfw_njobs -eq 1 ]; then _tfw_echo_progress $testPosition $testNumber "$testSourceFile" $testName $result echo elif ! $_tfw_verbose && lines=$($_tfw_tput lines); then local travel=$(($_tfw_test_number_watermark - $testPosition + 1)) if [ $travel -gt 0 -a $travel -lt $lines ] && $_tfw_tput cuu $travel ; then _tfw_echo_progress $testPosition $testNumber "$testSourceFile" $testName $result echo travel=$(($_tfw_test_number_watermark - $testPosition)) [ $travel -gt 0 ] && $_tfw_tput cud $travel fi else _tfw_echo_progress $testPosition $testNumber "$testSourceFile" $testName $result echo fi else _tfw_echoerr "${BASH_SOURCE[1]}: job %$job terminated without result" fi rm -f "$_tfw_results_dir/job-$job" } _tfw_echo_progress() { ( local script= if [ "$testSourceFile" != "$_tfw_invoking_script" ]; then _tfw_recursive_source=true source "$testSourceFile" unset _tfw_recursive_source script=" (${3#$_tfw_script_dir/})" fi local docvar="doc_$4" echo -n -e '\r' echo -n "$2 [" _tfw_echo_result "$5" echo -n "]$script ${!docvar:-$4}" ) [ $1 -gt $_tfw_test_number_watermark ] && _tfw_test_number_watermark=$1 } _tfw_echo_result() { local result="$1" case "$result" in ERROR | FATAL) $_tfw_tput setaf 1 $_tfw_tput rev echo -n "$result" $_tfw_tput sgr0 $_tfw_tput op ;; PASS) $_tfw_tput setaf 2 echo -n "$result" $_tfw_tput op echo -n "." ;; FAIL) $_tfw_tput setaf 1 echo -n "$result" $_tfw_tput op echo -n "." ;; *) result="$result....." echo -n "${result:0:5}" ;; esac } # The following functions can be overridden by a test script to provide a # default fixture for all test cases. setup() { : } finally() { : } teardown() { : } # The following functions are provided to facilitate writing test cases and # fixtures. # Add quotations to the given arguments to allow them to be expanded intact # in eval expressions. shellarg() { _tfw_shellarg "$@" echo "${_tfw_args[*]}" } # Echo the absolute path (containing symlinks if given) of the given # file/directory, which does not have to exist or even be accessible. abspath() { _tfw_abspath -L "$1" } # Echo the absolute path (resolving all symlinks) of the given file/directory, # which does not have to exist or even be accessible. realpath() { _tfw_abspath -P "$1" } # Escape all grep(1) basic regular expression metacharacters. escape_grep_basic() { local re="$1" local nil='' re="${re//[\\]/\\\\$nil}" re="${re//./\\.}" re="${re//\*/\\*}" re="${re//^/\\^}" re="${re//\$/\\$}" re="${re//\[/\\[}" re="${re//\]/\\]}" echo "$re" } # Escape all egrep(1) extended regular expression metacharacters. escape_grep_extended() { local re="$1" local nil='' re="${re//[\\]/\\\\$nil}" re="${re//./\\.}" re="${re//\*/\\*}" re="${re//\?/\\?}" re="${re//+/\\+}" re="${re//^/\\^}" re="${re//\$/\\$}" re="${re//(/\\(}" re="${re//)/\\)}" re="${re//|/\\|}" re="${re//\[/\\[}" re="${re//{/\\{}" echo "$re" } # Return true if all the arguments arg2... match the given grep(1) regular # expression arg1. matches_rexp() { _tfw_matches_rexp "$@" } # Create a file with the given size (default 0). # Usage: create_file [--append] [] # where: is of the form Nu # N is decimal integer # u is one of kKmMgG (k=10^3, K=2^10, m=10^6, M=2^20, g=10^9, G=2^30) create_file() { local args=("$@") case "$1" in --append) shift;; *) rm -f "$1";; esac local path="$1" local size="$2" tfw_createfile --label="$path" ${size:+--size=$size} >>"$path" || error "failed command: create_file ${args[*]}" } # Executes its arguments as a command: # - captures the standard output and error in temporary files for later # examination # - captures the exit status for later assertions # - sets the $executed variable to a description of the command that was # executed execute() { $_tfw_assert_noise && tfw_log "# execute" $(shellarg "$@") _tfw_getopts execute "$@" shift $_tfw_getopts_shift _tfw_execute "$@" } executeOk() { $_tfw_assert_noise && tfw_log "# executeOk" $(shellarg "$@") _tfw_getopts executeok "$@" _tfw_opt_exit_status=0 _tfw_dump_on_fail --stderr shift $_tfw_getopts_shift _tfw_execute "$@" } # Wait until a given condition is met: # - can specify the timeout with --timeout=SECONDS # - can specify the sleep interval with --sleep=SECONDS # - the condition is a command that is executed repeatedly until returns zero # status # where SECONDS may be fractional, eg, 1.5 wait_until() { $_tfw_assert_noise && tfw_log "# wait_until" $(shellarg "$@") local start=$SECONDS _tfw_getopts wait_until "$@" shift $_tfw_getopts_shift local sense= while [ "$1" = '!' ]; do sense="$sense !" shift done sleep ${_tfw_opt_timeout:-$_tfw_default_timeout} & local timeout_pid=$! while true; do "$@" [ $sense $? -eq 0 ] && break kill -0 $timeout_pid 2>/dev/null || fail "timeout" sleep ${_tfw_opt_sleep:-1} done local end=$SECONDS $_tfw_assert_noise && tfw_log "# waited for" $((end - start)) "seconds" return 0 } # Executes its arguments as a command in the current shell process (not in a # child process), so that side effects like functions setting variables will # have effect. # - if the exit status is non-zero, then fails the current test # - otherwise, logs a message indicating the assertion passed assert() { _tfw_getopts assert "$@" shift $_tfw_getopts_shift [ -z "$_tfw_message" ] && _tfw_message=$(shellarg "$@") _tfw_assert "$@" || _tfw_failexit || return $? $_tfw_assert_noise && tfw_log "# assert $_tfw_message" return 0 } assertExpr() { _tfw_getopts assertexpr "$@" shift $_tfw_getopts_shift _tfw_parse_expr "$@" || return $? _tfw_message="${_tfw_message:+$_tfw_message }("$@")" _tfw_shellarg "${_tfw_expr[@]}" _tfw_assert eval "${_tfw_args[@]}" || _tfw_failexit || return $? $_tfw_assert_noise && tfw_log "# assert $_tfw_message" return 0 } fail() { _tfw_getopts fail "$@" shift $_tfw_getopts_shift [ $# -ne 0 ] && _tfw_failmsg "$1" _tfw_backtrace _tfw_failexit } error() { _tfw_getopts error "$@" shift $_tfw_getopts_shift [ $# -ne 0 ] && _tfw_errormsg "$1" _tfw_backtrace _tfw_errorexit } fatal() { [ $# -eq 0 ] && set -- "no reason given" _tfw_fatalmsg "$@" _tfw_backtrace _tfw_fatalexit } fork() { local forkid=${#_tfw_forked_pids[*]} local _tfw_process_tmp="$_tfw_tmp/fork-$forkid" mkdir "$_tfw_process_tmp" || _tfw_fatalexit $_tfw_assert_noise && tfw_log "# fork[$forkid] START" $(shellarg "$@") ( "$@" ) 6>"$_tfw_process_tmp/log.stdout" 1>&6 2>"$_tfw_process_tmp/log.stderr" 7>"$_tfw_process_tmp/log.xtrace" & _tfw_forked_pids[$forkid]=$! $_tfw_assert_noise && tfw_log "# fork[$forkid] pid=$! STARTED" } forkKillAll() { $_tfw_assert_noise && tfw_log "# fork kill all" local forkid for ((forkid=0; forkid < ${#_tfw_forked_pids[*]}; ++forkid)); do local pid=${_tfw_forked_pids[$forkid]} [ -z "$pid" ] && continue $_tfw_assert_noise && tfw_log "# fork[$forkid] pid=$pid KILL" kill -TERM $pid 2>/dev/null done } forkWaitAll() { _tfw_getopts forkwaitall "$@" shift $_tfw_getopts_shift $_tfw_assert_noise && tfw_log "# fork wait all" while true; do local running=0 local forkid for ((forkid=0; forkid < ${#_tfw_forked_pids[*]}; ++forkid)); do local pid=${_tfw_forked_pids[$forkid]} [ -z "$pid" ] && continue if kill -0 $pid 2>/dev/null; then let running+=1 else _tfw_forked_pids[$forkid]= wait $pid # should not block because process has exited local status=$? $_tfw_assert_noise && tfw_log "# fork[$forkid] pid=$pid EXIT status=$status" echo "++++++++++ fork[$forkid] log.stdout ++++++++++" cat $_tfw_tmp/fork-$forkid/log.stdout echo "++++++++++" echo "++++++++++ fork[$forkid] log.stderr ++++++++++" cat $_tfw_tmp/fork-$forkid/log.stderr echo "++++++++++" if $_tfw_trace; then echo "++++++++++ fork[$forkid] log.xtrace ++++++++++" cat $_tfw_tmp/fork-$forkid/log.xtrace echo "++++++++++" fi case $status in 0) ;; 1) _tfw_fail "fork[$forkid] process exited with FAIL status";; 254) _tfw_error "fork[$forkid] process exited with ERROR status";; 255) _tfw_fatal "fork[$forkid] process exited with FATAL status";; *) _tfw_error "fork[$forkid] process exited with status=$status";; esac fi done [ $running -eq 0 ] && return 0 done } # Append a time stamped message to the test case's stdout log. Will work even # in a context that stdout (fd 1) is redirected. Will not log anything in a # quietened region. tfw_log() { if $_tfw_log_noise; then local ts=$(_tfw_timestamp) cat >&$_tfw_log_fd </dev/null local stat=$? _tfw_log_noise=$old_log_noise return $stat fi } # Execute the given command with successful assert log messages quietened. tfw_quietly() { if [ "$1" = ! ]; then shift ! tfw_quietly "$@" else local old_assert_noise=$_tfw_assert_noise _tfw_assert_noise=false "$@" >/dev/null local stat=$? _tfw_assert_noise=$old_assert_noise return $stat fi } # Append the contents of a file to the test case's stdout log. A normal 'cat' # to stdout would also do this, but tfw_cat echoes header and footer delimiter # lines around to content to help distinguish it, and also works even in a # context that stdout (fd 1) is redirected. tfw_cat() { local header= local show_nonprinting= for file; do case $file in --header=*) header="${1#*=}" continue ;; -v|--show-nonprinting) show_nonprinting=-v continue ;; --stdout) file="$_tfw_process_tmp/stdout" header="${header:-stdout of ($executed)}" ;; --stderr) file="$_tfw_process_tmp/stderr" header="${header:-stderr of ($executed)}" ;; *) header="${header:-${file#$_tfw_tmp/}}" ;; esac local missing_nl= tfw_log "#----- $header -----" cat $show_nonprinting "$file" >&$_tfw_log_fd if [ "$(tail -1c "$file")" != "$newline" ]; then echo >&$_tfw_log_fd missing_nl=" (no newline at end)" fi tfw_log "#-----$missing_nl" header= show_nonprinting= done } tfw_multicolumn() { $AWK ' function pad(s, n) { return sprintf("%-" n "s", s) } { line[nlines++] = $0 if (length($0) > colwid) colwid = length($0) } END { wid = 0 + ENVIRON["COLUMNS"] if (wid != 0) { ncol = int(wid / (colwid + 2)) if (ncol < 1) ncol = 1 } collen = int((nlines + ncol - 1) / ncol) for (r = 0; r < collen; ++r) { for (c = 0; c < ncol; ++c) { i = c * collen + r if (i >= nlines) break printf "%s ", pad(line[i], colwid) } printf "\n" } } ' } tfw_core_backtrace() { local executable="$1" local corefile="$2" echo backtrace >"$_tfw_process_tmp/backtrace.gdb" tfw_log "#----- gdb backtrace from $executable $corefile -----" gdb -n -batch -x "$_tfw_process_tmp/backtrace.gdb" "$executable" "$corefile" "$_tfw_process_tmp/stdout" 2>"$_tfw_process_tmp/stderr" ; } 2>"$_tfw_process_tmp/times" _tfw_exitStatus=$? # Deal with core dump. if $_tfw_opt_core_backtrace && [ -s core ]; then tfw_core_backtrace "$_tfw_executable" core fi # Deal with exit status. if [ -n "$_tfw_opt_exit_status" ]; then _tfw_message="exit status ($_tfw_exitStatus) of ($executed) is $_tfw_opt_exit_status" _tfw_dump_stderr_on_fail=true _tfw_assert [ "$_tfw_exitStatus" -eq "$_tfw_opt_exit_status" ] || _tfw_failexit || return $? $_tfw_assert_noise && tfw_log "# assert $_tfw_message" else $_tfw_assert_noise && tfw_log "# exit status of ($executed) = $_tfw_exitStatus" fi # Parse execution time report. if ! _tfw_parse_times_to_milliseconds real realtime_ms || ! _tfw_parse_times_to_milliseconds user usertime_ms || ! _tfw_parse_times_to_milliseconds sys systime_ms then tfw_log '# malformed output from time:' tfw_cat -v "$_tfw_process_tmp/times" fi return 0 } _tfw_parse_times_to_milliseconds() { local label="$1" local var="$2" local milliseconds=$($AWK '$1 == "'"$label"'" { value = $2 minutes = 0 if (match(value, "[0-9]+m")) { minutes = substr(value, RSTART, RLENGTH - 1) value = substr(value, 1, RSTART - 1) substr(value, RSTART + RLENGTH) } if (substr(value, length(value)) == "s") { value = substr(value, 1, length(value) - 1) } if (match(value, "^[0-9]+(\\.[0-9]+)?$")) { seconds = value + 0 print (minutes * 60 + seconds) * 1000 } }' $_tfw_process_tmp/times) [ -z "$milliseconds" ] && return 1 [ -n "$var" ] && eval $var=$milliseconds return 0 } _tfw_assert() { local sense= while [ "$1" = '!' ]; do sense="$sense !" shift done "$@" if [ $sense $? -ne 0 ]; then _tfw_failmsg "assertion failed: ${_tfw_message:-$*}" _tfw_backtrace return 1 fi return 0 } declare -a _tfw_opt_dump_on_fail _tfw_dump_on_fail() { local arg for arg; do local _found=false local _f for _f in "${_tfw_opt_dump_on_fail[@]}"; do if [ "$_f" = "$arg" ]; then _found=true break fi done $_found || _tfw_opt_dump_on_fail+=("$arg") done } _tfw_getopts() { local context="$1" shift _tfw_executable= _tfw_opt_core_backtrace=false _tfw_message= _tfw_opt_dump_on_fail=() _tfw_opt_error_on_fail=false _tfw_opt_exit_status= _tfw_opt_timeout= _tfw_opt_sleep= _tfw_opt_matches= _tfw_opt_line= _tfw_opt_line_sed= _tfw_opt_line_msg= _tfw_getopts_shift=0 local oo _tfw_shopt oo -s extglob while [ $# -ne 0 ]; do case "$context:$1" in *:--stdout) _tfw_dump_on_fail --stdout;; *:--stderr) _tfw_dump_on_fail --stderr;; assert*:--dump-on-fail=*) _tfw_dump_on_fail "${1#*=}";; @(assert*|forkwait*):--error-on-fail) _tfw_opt_error_on_fail=true;; assert*:--message=*) _tfw_message="${1#*=}";; execute:--exit-status=+([0-9])) _tfw_opt_exit_status="${1#*=}";; execute:--exit-status=*) _tfw_error "invalid value: $1";; execute*:--executable=) _tfw_error "missing value: $1";; execute*:--executable=*) _tfw_executable="${1#*=}";; execute*:--core-backtrace) _tfw_opt_core_backtrace=true;; wait_until:--timeout=@(+([0-9])?(.+([0-9]))|*([0-9]).+([0-9]))) _tfw_opt_timeout="${1#*=}";; wait_until:--timeout=*) _tfw_error "invalid value: $1";; wait_until:--sleep=@(+([0-9])?(.+([0-9]))|*([0-9]).+([0-9]))) _tfw_opt_sleep="${1#*=}";; wait_until:--sleep=*) _tfw_error "invalid value: $1";; assertcontentgrep:--matches=+([0-9])) _tfw_opt_matches="${1#*=}";; assertcontentgrep:--matches=*) _tfw_error "invalid value: $1";; assertcontent*:--line=+([0-9])) _tfw_opt_line="${1#*=}"; _tfw_opt_line_msg="line $_tfw_opt_line";; assertcontent*:--line=+([0-9])..) _tfw_opt_line="${1#*=}\$"; _tfw_opt_line_msg="lines $_tfw_opt_line";; assertcontent*:--line=..+([0-9])) _tfw_opt_line="1${1#*=}"; _tfw_opt_line_msg="lines $_tfw_opt_line";; assertcontent*:--line=+([0-9])..+([0-9])) _tfw_opt_line="${1#*=}"; _tfw_opt_line_msg="lines $_tfw_opt_line";; assertcontent*:--line=*) _tfw_error "invalid value: $1";; *:--) let _tfw_getopts_shift=_tfw_getopts_shift+1; shift; break;; *:--*) _tfw_error "unsupported option: $1";; *) break;; esac let _tfw_getopts_shift=_tfw_getopts_shift+1 shift done [ -n "$_tfw_opt_line" ] && _tfw_opt_line_sed="${_tfw_opt_line/../,}" case "$context" in execute*) if [ -z "$_tfw_executable" ]; then _tfw_executable="$1" let _tfw_getopts_shift=_tfw_getopts_shift+1 shift fi [ -z "$_tfw_executable" ] && _tfw_error "missing executable argument" ;; esac _tfw_shopt_restore oo return 0 } _tfw_matches_rexp() { local rexp="$1" shift local arg for arg; do if ! echo "$arg" | $GREP -q -e "$rexp"; then return 1 fi done return 0 } _tfw_parse_expr() { local _expr="$*" _tfw_expr=() while [ $# -ne 0 ]; do case "$1" in '&&' | '||' | '!' | '(' | ')') _tfw_expr+=("$1") shift ;; *) if [ $# -lt 3 ]; then _tfw_error "invalid expression: $_expr" return $? fi case "$2" in '==') _tfw_expr+=("[" "$1" "-eq" "$3" "]");; '!=') _tfw_expr+=("[" "$1" "-ne" "$3" "]");; '<=') _tfw_expr+=("[" "$1" "-le" "$3" "]");; '<') _tfw_expr+=("[" "$1" "-lt" "$3" "]");; '>=') _tfw_expr+=("[" "$1" "-ge" "$3" "]");; '>') _tfw_expr+=("[" "$1" "-gt" "$3" "]");; '~') _tfw_expr+=("_tfw_matches_rexp" "$3" "$1");; '!~') _tfw_expr+=("!" "_tfw_matches_rexp" "$3" "$1");; *) _tfw_error "invalid expression: $_expr" return $? ;; esac shift 3 ;; esac done return 0 } _tfw_assertExpr() { _tfw_parse_expr "$@" || return $? _tfw_shellarg "${_tfw_expr[@]}" _tfw_assert eval "${_tfw_args[@]}" } _tfw_get_content() { case "$_tfw_opt_line_sed" in '') ln -f "$1" "$_tfw_process_tmp/content" || error "ln failed";; *) $SED -n -e "${_tfw_opt_line_sed}p" "$1" >"$_tfw_process_tmp/content" || error "sed failed";; esac } _tfw_assert_stdxxx_is() { local qual="$1" shift _tfw_getopts assertcontentis --$qual --stderr "$@" shift $((_tfw_getopts_shift - 2)) if [ $# -lt 1 ]; then _tfw_error "incorrect arguments" return $? fi [ -r "$_tfw_process_tmp/$qual" ] || fail "no $qual" _tfw_get_content "$_tfw_process_tmp/$qual" || return $? local message="${_tfw_message:-${_tfw_opt_line_msg:+$_tfw_opt_line_msg of }$qual of ($executed) is $(shellarg "$@")}" echo -n "$@" >"$_tfw_process_tmp/stdxxx_is.tmp" if ! cmp -s "$_tfw_process_tmp/stdxxx_is.tmp" "$_tfw_process_tmp/content"; then _tfw_failmsg "assertion failed: $message" _tfw_backtrace return 1 fi $_tfw_assert_noise && tfw_log "# assert $message" return 0 } _tfw_assert_stdxxx_linecount() { local qual="$1" shift _tfw_getopts assert --$qual --stderr "$@" shift $((_tfw_getopts_shift - 2)) if [ $# -lt 1 ]; then _tfw_error "incorrect arguments" return $? fi [ -r "$_tfw_process_tmp/$qual" ] || fail "no $qual" local lineCount=$(( $(cat "$_tfw_process_tmp/$qual" | wc -l) + 0 )) [ -z "$_tfw_message" ] && _tfw_message="$qual line count ($lineCount) $*" _tfw_assertExpr "$lineCount" "$@" || _tfw_failexit || return $? $_tfw_assert_noise && tfw_log "# assert $_tfw_message" return 0 } _tfw_assert_stdxxx_grep() { local qual="$1" shift _tfw_getopts assertcontentgrep --$qual --stderr "$@" shift $((_tfw_getopts_shift - 2)) if [ $# -ne 1 ]; then _tfw_error "incorrect arguments" return $? fi [ -r "$_tfw_process_tmp/$qual" ] || fail "no $qual" _tfw_get_content "$_tfw_process_tmp/$qual" || return $? _tfw_assert_grep "${_tfw_opt_line_msg:+$_tfw_opt_line_msg of }$qual of ($executed)" "$_tfw_process_tmp/content" "$@" } _tfw_assert_grep() { local label="$1" local file="$2" local pattern="$3" local message= if ! [ -e "$file" ]; then _tfw_error "$file does not exist" ret=$? elif ! [ -f "$file" ]; then _tfw_error "$file is not a regular file" ret=$? elif ! [ -r "$file" ]; then _tfw_error "$file is not readable" ret=$? else local matches=$(( $($GREP --regexp="$pattern" "$file" | wc -l) + 0 )) local done=false local ret=0 local info="$matches match"$([ $matches -ne 1 ] && echo "es") local oo _tfw_shopt oo -s extglob case "$_tfw_opt_matches" in '') done=true message="${_tfw_message:-$label contains a line matching \"$pattern\"}" if [ $matches -ne 0 ]; then $_tfw_assert_noise && tfw_log "# assert $message" else _tfw_failmsg "assertion failed ($info): $message" ret=1 fi ;; esac case "$_tfw_opt_matches" in +([0-9])) done=true local s=$([ $_tfw_opt_matches -ne 1 ] && echo s) message="${_tfw_message:-$label contains exactly $_tfw_opt_matches line$s matching \"$pattern\"}" if [ $matches -eq $_tfw_opt_matches ]; then $_tfw_assert_noise && tfw_log "# assert $message" else _tfw_failmsg "assertion failed ($info): $message" ret=1 fi ;; esac case "$_tfw_opt_matches" in +([0-9])-*([0-9])) done=true local bound=${_tfw_opt_matches%-*} local s=$([ $bound -ne 1 ] && echo s) message="${_tfw_message:-$label contains at least $bound line$s matching \"$pattern\"}" if [ $matches -ge $bound ]; then $_tfw_assert_noise && tfw_log "# assert $message" else _tfw_failmsg "assertion failed ($info): $message" ret=1 fi ;; esac case "$_tfw_opt_matches" in *([0-9])-+([0-9])) done=true local bound=${_tfw_opt_matches#*-} local s=$([ $bound -ne 1 ] && echo s) message="${_tfw_message:-$label contains at most $bound line$s matching \"$pattern\"}" if [ $matches -le $bound ]; then $_tfw_assert_noise && tfw_log "# assert $message" else _tfw_failmsg "assertion failed ($info): $message" ret=1 fi ;; esac if ! $done; then _tfw_error "unsupported value for --matches=$_tfw_opt_matches" ret=$? fi _tfw_shopt_restore oo fi if [ $ret -ne 0 ]; then _tfw_backtrace fi return $ret } # Write a message to the real stderr of the test script, so the user sees it # immediately. Also write the message to the test log, so it can be recovered # later. _tfw_echoerr() { echo "$@" >&$_tfw_stderr if [ $_tfw_stderr -ne 2 ]; then echo "$@" >&2 fi } _tfw_checkBashVersion() { [ -z "$BASH_VERSION" ] && _tfw_fatal "not running in Bash (/bin/bash) shell" if [ -n "${BASH_VERSINFO[*]}" ]; then [ ${BASH_VERSINFO[0]} -gt 3 ] && return 0 if [ ${BASH_VERSINFO[0]} -eq 3 ]; then [ ${BASH_VERSINFO[1]} -gt 2 ] && return 0 if [ ${BASH_VERSINFO[1]} -eq 2 ]; then [ ${BASH_VERSINFO[2]} -ge 48 ] && return 0 fi fi fi _tfw_fatal "unsupported Bash version: $BASH_VERSION" } _tfw_checkTerminfo() { _tfw_tput=false case $(type -p tput) in */tput) _tfw_tput=tput;; esac } _tfw_checkCommandInPATH() { case $(type -p "$1") in */"${1##*/}") ;; *) _tfw_fatal "command not found: $1 (PATH=$PATH)" esac } _tfw_unpack_words() { local __text="$1" shift while [ $# -gt 1 ]; do eval "$1=\"\${__text%% *}\"" shift __text="${__text#* }" done if [ $# -ne 0 ]; then eval "$1=\"\$__text\"" fi } _tfw_find_tests() { ( local oo _tfw_shopt oo -s extdebug local func for func in $(builtin declare -F | $SED -n -e '/^declare -f test_[A-Za-z]/s/^declare -f //p'); do local funcname local lineno local path _tfw_unpack_words "$(builtin declare -F $func)" funcname lineno path echo $lineno 0 ${funcname#test_} "$(abspath "$path")" done local include for include in "${_tfw_included_tests[@]}"; do local lineno local script _tfw_unpack_words "$include" lineno script local listline "$BASH" "$_tfw_script_dir/$script" --list 2>/dev/null | while read listline; do local number local name local path _tfw_unpack_words "$listline" number name path case "$path" in /*) ;; *) path="$(abspath "$_tfw_script_dir/$path")";; esac echo $lineno $number $name "$path" done done _tfw_shopt_restore oo ) | sort -n -k1 -k2 | $SED -e 's/^[0-9][0-9]* [0-9][0-9]* //' } # Return a list of test names in the _tfw_test_sourcefiles and _tfw_test_names # array variables, in the order that the test_TestName functions were defined. # Test names must start with an alphabetic character (not numeric or '_'). _tfw_list_tests() { _tfw_test_sourcefiles=() _tfw_test_names=() local oIFS="$IFS" IFS=" " local -a testlines=($(_tfw_find_tests)) IFS="$oIFS" local testline for testline in "${testlines[@]}"; do local name local path _tfw_unpack_words "$testline" name path _tfw_test_names+=("$name") _tfw_test_sourcefiles+=("$path") done } _tfw_filter_predicate() { local number="$1" local name="$2" shift 2 local -a filters=("$@") local ret=1 local oo _tfw_shopt oo -s extglob if [ ${#filters[*]} -eq 0 ]; then ret=0 else local filter for filter in "${filters[@]}"; do case "$filter" in +([0-9])) if [ $number -eq $filter ]; then ret=0 break fi ;; +([0-9])*(,+([0-9]))) local oIFS="$IFS" IFS=, local -a numbers=($filter) IFS="$oIFS" local n for n in ${numbers[*]}; do if [ $number -eq $n ]; then ret=0 break 2 fi done ;; +([0-9])-) local start=${filter%-} if [ $number -ge $start ]; then ret=0 break fi ;; -+([0-9])) local end=${filter#-} if [ $number -le $end ]; then ret=0 break fi ;; +([0-9])-+([0-9])) local start=${filter%-*} local end=${filter#*-} if [ $number -ge $start -a $number -le $end ]; then ret=0 break fi ;; *) case "$name" in "$filter"*) ret=0; break;; esac ;; esac done fi _tfw_shopt_restore oo return $ret } # A "fail" event occurs when any assertion fails, and indicates that the test # has not passed. Other tests may still proceed. A "fail" event during setup # or teardown is treated as an error, not a failure. _tfw_failmsg() { # A failure during setup or teardown is treated as an error. case $_tfw_phase in testcase|finalise) if ! $_tfw_opt_error_on_fail; then tfw_log "FAIL: $*" return 0; fi ;; esac tfw_log "ERROR: $*" } _tfw_fail() { _tfw_failmsg "$*" _tfw_backtrace _tfw_failexit } _tfw_backtrace() { tfw_log '#----- shell backtrace -----' local -i up=1 while [ "${BASH_SOURCE[$up]}" == "${BASH_SOURCE[0]}" ]; do let up=up+1 done local -i i=0 while [ $up -lt ${#FUNCNAME[*]} -a "${BASH_SOURCE[$up]}" != "${BASH_SOURCE[0]}" ]; do echo "[$i] ${FUNCNAME[$(($up-1))]}() called from ${FUNCNAME[$up]}() at line ${BASH_LINENO[$(($up-1))]} of ${BASH_SOURCE[$up]}" >&$_tfw_log_fd let up=up+1 let i=i+1 done tfw_log '#-----' } _tfw_failexit() { # When exiting a test case due to a failure, log any diagnostic output that # has been requested. tfw_cat "${_tfw_opt_dump_on_fail[@]}" # A failure during setup or teardown is treated as an error. A failure # during finalise does not terminate execution. case $_tfw_phase in testcase) if ! $_tfw_opt_error_on_fail; then exit 1 fi ;; finalise) if ! $_tfw_opt_error_on_fail; then case $_tfw_result in PASS) _tfw_result=FAIL; _tfw_status=1;; esac return 1 fi ;; esac _tfw_errorexit } # An "error" event prevents a test from running, so it neither passes nor fails. # Other tests may still proceed. _tfw_errormsg() { [ $# -eq 0 ] && set -- "(no message)" local -i up=1 local -i top=${#FUNCNAME[*]} let top=top-1 while [ $up -lt $top -a "${BASH_SOURCE[$up]}" == "${BASH_SOURCE[0]}" ]; do let up=up+1 done tfw_log "ERROR: in ${FUNCNAME[$up]}: $*" } _tfw_error() { _tfw_errormsg "$*" _tfw_backtrace _tfw_errorexit } _tfw_errorexit() { # Do not exit process during finalise or teardown _tfw_result=ERROR case $_tfw_phase in finalise|teardown) [ $_tfw_status -lt 254 ] && _tfw_status=254;; *) exit 254;; esac return 254 } # A "fatal" event stops the entire test run, and generally indicates an # insurmountable problem in the test script or in the test framework itself. _tfw_fatalmsg() { _tfw_echoerr "${BASH_SOURCE[1]}: FATAL: $*" } _tfw_fatal() { [ $# -eq 0 ] && set -- exiting _tfw_echoerr "${BASH_SOURCE[1]}: FATAL: $*" _tfw_fatalexit } _tfw_fatalexit() { exit 255 } # Restore the caller's shopt preferences before returning. _tfw_shopt_restore _tfw_orig_shopt