#!/usr/bin/env bash # # Minimal test runner with pretty output # # Copyright (c) 2018 Tor Arne Vestbø # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, # DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE # OR OTHER DEALINGS IN THE SOFTWARE. # # ---------------------------------------------------------- pgid=$(ps -o pgid= $$) if [[ $pgid -ne $$ ]]; then if [[ $(uname -s) == "Darwin" ]]; then exec script -q /dev/null $0 $* else exec setsid $0 $* fi fi if [[ -t 1 ]] && [[ $(tput colors) -ge 8 ]]; then declare -i counter=0 for color in Black Red Green Yellow Blue Magenta Cyan White; do declare -r k${color}="\033[$((30 + $counter))m" declare -r k${color}Background="\033[$((40 + $counter))m" counter+=1 done declare -r kReset="\033[0m" declare -r kBold="\033[1m" declare -r kDark="\033[2m" declare -r kUnderline="\033[4m" declare -r kInverse="\033[7m" fi function testrunner::function_declared() { test "$(type -t $1)" = 'function' } function testrunner::absolute_path() { printf "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" } function testrunner::pid() { # Portable subshell-aware PID exec bash -c 'echo $PPID' } declare test_output_dir=$(mktemp -d) function testrunner::run_test() { local testcase=$1 local pretty_testcase=${testcase#test_} local pretty_testcase=${pretty_testcase//[_]/ } printf -- "- ${pretty_testcase} " test_failure="" trap 'testrunner::register_failure "$BASH_COMMAND" $? && return' ERR INT # Work around older bash versions not getting location correct on error set -o functrace local -a actual_lineno local -a actual_source trap 'actual_lineno+=($LINENO); actual_source+=(${BASH_SOURCE[0]})' DEBUG ${testcase} >>$test_output_file 2>&1 trap - ERR INT DEBUG if [[ -z "$test_failure" ]]; then printf "${kGreen}✔${kReset}\n" if [[ $DEBUG -eq 1 ]]; then testrunner::print_test_output fi return 0 else tests_failed+=1 printf "${kRed}✘${kReset}\n" IFS='|' read -r filename line_number expression \ evaluated_expression exit_code <<< "$test_failure" testrunner::print_location $filename $line_number printf "Expression:\n\n" printf " ${kBold}${expression}${kReset}" if [[ $evaluated_expression != $expression ]]; then printf " (${evaluated_expression})" fi printf "\n\nFailed with exit code ${kBold}${exit_code}${kReset}\n" testrunner::print_test_output return 1 fi } function testrunner::run_tests() { local pretty_testsuite=$(basename $testsuite) local test_output_file="${test_output_dir}/${pretty_testsuite}.log" touch $test_output_file exec 4< $test_output_file local all_testcases=($(grep "function .*()" $testsuite | grep -o "test_[a-zA-Z_]*")) for testcase_num in "${!all_testcases[@]}"; do testcase="${all_testcases[$testcase_num]}" # Make sure testcase is actually a defined function if ! testrunner::function_declared $testcase; then unset 'all_testcases[testcase_num]' fi done if [[ -z $all_testcases ]]; then printf "${kUnderline}No tests in ${pretty_testsuite}${kReset}\n\n" return; fi local requested_testcases=$testcases if [[ -z $testcases ]]; then testcases=("${all_testcases[@]}") else local -a matching_testcases for testcase in "${testcases[@]}"; do if [[ "${all_testcases[*]}" =~ (^| )(test_)?${testcase}( |$) ]]; then matching_testcases+=(${BASH_REMATCH[0]}) fi done testcases=("${matching_testcases[@]}") fi if [[ -z $testcases ]]; then printf "${kUnderline}No matching tests for '$requested_testcases' in ${pretty_testsuite}${kReset}\n\n" return; fi printf "${kUnderline}Running ${#testcases[@]} tests from ${pretty_testsuite}...${kReset}\n\n" if testrunner::function_declared setup; then testrunner::run_test setup test $? -eq 0 || return fi local test_failure for testcase in "${testcases[@]}" ; do tests_total+=1 testrunner::run_test $testcase test $? -eq 0 || break done if testrunner::function_declared teardown; then testrunner::run_test teardown fi # Clean up if test didn't do it testrunner::signal_children TERM testrunner::signal_children KILL if [[ -z "$test_failure" ]]; then printf "\n" # Blank line in case the last test passed fi exec 4>&- } set -o errtrace function testrunner::register_failure() { trap - DEBUG if [[ ! -z "$test_failure" ]]; then return; # Already processing a failure fi #for (( f=${#actual_source[@]}; f >= 0; f-- )); do # echo "${actual_source[$f]}:${actual_lineno[$f]}" #done local line=${actual_lineno[${#actual_lineno[@]} - 4]} local filename=${actual_source[${#actual_source[@]} - 5]} local command=$1 local exit_code=$2 test_failure="${filename}|${line}|${command}|$(eval "echo ${command}")|${exit_code}" } function testrunner::print_location() { local filename=$1 local line_number=$2 printf "\n${kBlack}${kBold}${filename}:${line_number}${kReset}\n\n" local -r -i context_lines=2 # FIXME: Start at function? local -i context_above=$context_lines local -i context_below=$context_lines test $context_above -ge $line_number && context_above=$(($line_number - 1)) local -i diff_start=${line_number}-${context_above} local -i total_lines=$(($context_above + 1 + $context_below)) local -i current_line=${diff_start} tail -n "+${diff_start}" ${filename} | head -n $total_lines | while IFS='' read -r line; do if [ $current_line -eq $line_number ]; then # FIXME: Compute longest line and color all the way printf " ${kRedBackground}${kBold}${current_line}:${kReset}${kRedBackground}" else printf " ${kBlack}${kBold}${current_line}:${kReset}" fi printf " ${line}${kReset}\n" current_line+=1 done printf "\n" } function testrunner::print_test_output { header=${1:-Output} local -i wrote_header=0 while IFS= read -r line || [[ -n "$line" ]]; do if [[ ! $wrote_header -eq 1 ]]; then printf "\n${header}:\n\n" wrote_header=1 fi printf " ${kMagenta}|${kReset} $line\n" done <&4 if [[ $wrote_header -eq 1 ]]; then printf "\n" return 0 else return 1 fi } function testrunner::signal_children() { local signal=${1:-TERM} local subshell_pid=$(testrunner::pid) local pid=${2:-${BASHPID:-${subshell_pid:-$$}}} local child_pids=() IFS= res=$(ps -o pgid,ppid,pid) unset IFS { read -r # Skip header while IFS=' ' read -r pgid ppid cpid; do # Child processes #test $ppid -eq $pid && child_pids+=($cpid) # Process group children test $pgid -eq $pid && test $cpid -ne $pid && child_pids+=($cpid) done }<<<"$res" IFS=$'\n' child_pids=($(sort --reverse <<<"${child_pids[*]}")) for p in "${child_pids[@]}"; do #testrunner::signal_children $signal $p #echo "Signaling $p ($(ps -o pid=,command= $p)) $signal" kill -$signal $p >/dev/null 2>&1 done } function testrunner::teardown() { testrunner::signal_children KILL rm -Rf $test_output_dir } function testrunner::print_summary() { if [[ $tests_failed -gt 0 || ($tests_total -eq 0 && ${#testcases[@]} -gt 0) ]]; then printf "${kRed}FAIL${kReset}" else printf "${kGreen}OK${kReset}" fi printf ": $tests_total tests" if [[ $tests_total -gt 0 ]]; then printf ", $tests_failed failures\n" return $tests_failed else printf "\n" return 1 fi } trap 'testrunner::teardown; testrunner::print_summary; exit $?' EXIT declare -a testsuites declare -a testcases for argument in "$@"; do if [[ -f "$argument" ]]; then testsuites+=("$argument") else testcases+=("$argument") fi done declare -i tests_total=0 declare -i tests_failed=0 declare interrupted=0 trap 'interrupted=1' INT printf "\n" for testsuite in "${testsuites[@]}"; do exec 4>&1 eval $( exec 3>&1 # Set up file descriptor for exporting variables exec 1>&4- # Ensure stdout still goes to the right place source "$testsuite" tests_total=0 tests_failed=0 testrunner::run_tests # Export results out of sub-shell printf "tests_total+=${tests_total}; tests_failed+=${tests_failed}" >&3 testrunner::signal_children TERM $$ testrunner::signal_children KILL $$ ) exec 4>&- if [[ $interrupted -eq 1 ]]; then break; fi done