Created
November 24, 2011 23:28
-
-
Save np/1392508 to your computer and use it in GitHub Desktop.
cmdcheck & cmdrecord
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash -e | |
# Current bashisms are: | |
# a=(foo bar baz) | |
# ${foo[@]} | |
# $(bar) | |
# local | |
# (( bar )) | |
# for i; do; ...; done is not a bashism, right? | |
### The record command takes as arguments a name for the test | |
### and a command to run. | |
### The test name will serve as the destination directory where | |
### files will be stored. | |
### Then it inspects its running environment and stores it. | |
### Then it runs the command in a similar but possibly | |
### isolated environment. | |
### Then it store the outputs. | |
### Finally it produce a shell script that looks like: | |
### | |
### testname='foobar1' | |
### command='/usr/bin/foobar' | |
### args=('--foo' 'bar' 'baz') | |
### exit_code=0 | |
### stdin_file='stdin' | |
### stdout_file='stdout' | |
### stderr_file='stderr' | |
### | |
### # Environment variables: | |
### env_vars=('PATH' 'USER') # ... | |
### env_var_PATH='/usr/bin/...' | |
### env_var_USER='me' | |
### Exit codes of cmdcheck itself: | |
# 1 means an error from the user of this script | |
# 2 means an failure from the tested program | |
# 3 means an internal error from the author of this script | |
## TODO | |
## add an update action | |
## more tests of the tool itself including details like: | |
## * testing the check of several tests at once | |
## HOW TO | |
## have a precise handling of env and $PATH | |
## * if you use --env pass:PATH, then it will always use the current one | |
## * you can use --env empty to start with an empty env | |
## * expands the path to the command to an absolute path | |
## LIMITATIONS | |
## Since currently any command is expanded using `which' in the checking mode, | |
## one cannot easily test that a command when called non absolutely. | |
## A workaround is to test env cmd args instead of cmd args, then only env | |
## will be expanded. | |
## NOTES on the explain function hook | |
## This function and the functions it calls are supposed to write on the | |
## standard output. | |
me=$(basename "$0") | |
batch_mode=0 | |
print_directory=0 | |
log_level=0 | |
log_file=/dev/stderr | |
chroot_args=( ) #/usr/sbin/chroot .) | |
info(){ | |
echo "$me: $@" >>"$log_file" | |
} | |
cat_info(){ | |
cat "$@" >>"$log_file" | |
} | |
nest2_info(){ | |
cat "$@" | nest2 >>"$log_file" | |
} | |
log(){ | |
lvl="$1"; shift | |
[ "$lvl" -gt "$log_level" ] || info "$@" | |
} | |
log_w(){ | |
(( ! print_directory )) || info "$@" | |
} | |
if tty -s; then | |
CR='\r' | |
color(){ | |
col="$1" | |
shift | |
printf "\e[${col}m$@\e[m" | |
} | |
else | |
CR='\n' | |
color(){ | |
shift | |
echo -n "$@" | |
} | |
fi | |
red(){ color '1;31' "$@"; } | |
green(){ color '1;32' "$@"; } | |
yellow(){ color '1;33' "$@"; } | |
nest2(){ | |
sed -e 's/^/ /' | |
} | |
get_var(){ | |
eval 'echo $'"$1" | |
} | |
# TODO: handle escapes | |
show_string(){ | |
printf "'%s'" "$1" | |
} | |
show_list(){ | |
printf '(' | |
for i; do | |
printf ' ' | |
show_string "$i" | |
done | |
echo ' )' | |
} | |
# Expect a valid variable name | |
show_string_var(){ | |
eval 'show_string "$'$1\" | |
} | |
# Expect valid variable names | |
patch_filename_vars(){ | |
local file= | |
for var; do | |
file="$(get_var $var)" | |
if [ ! -s "$file" ]; then | |
[ ! -f ./"$file" ] || rm ./"$file" | |
eval $var=/dev/null | |
fi | |
done | |
} | |
# Expect a valid variable name | |
show_env_var(){ | |
if (( "$(get_var pass_$1$2)" )); then | |
echo '"$'$2\" | |
else | |
show_string_var $1$2 | |
fi | |
} | |
show_vars(){ | |
prefix="$1" | |
shift | |
for var; do | |
echo $prefix$var=$(show_env_var $prefix $var) | |
done | |
} | |
error(){ | |
echo >>"$log_file" | |
info "$@" | |
exit 1 | |
} | |
internal_error(){ | |
info "$@" | |
exit 3 | |
} | |
asking() { | |
if (( batch_mode )); then | |
false | |
else | |
read -p "$@ (Y/n)" answer | |
case "$answer" in | |
n|N|no|NO|No|nO) false;; | |
*) true;; | |
esac | |
fi | |
} | |
ensure_empty_dir(){ | |
local force=0 | |
case "$1" in | |
-f) shift; force=1;; | |
*) ;; | |
esac | |
dir="$1" | |
if [ -d "$dir" ]; then | |
if [ -n "$(ls "$dir")" ]; then | |
if (( ! force )); then | |
info "$(show_string "$dir")" is not empty | |
asking "Do you want to overwrite it?" || exit 1 | |
fi | |
rm -r "$dir" | |
mkdir "$dir" | |
fi | |
elif [ -e "$dir" ]; then | |
error "$(show_string "$dir")" already exists and is not a directory | |
else | |
mkdir "$dir" | |
fi | |
} | |
print_function(){ | |
name="$1" | |
shift | |
echo "$name(){" | |
if [ "$#" = 0 ]; then | |
echo "true" | |
else | |
echo -n " " | |
for word; do | |
if [ "$word" = \; ]; then | |
echo | |
echo -n " " | |
else | |
echo -n " $word" | |
fi | |
done | |
fi | |
echo | |
echo "}" | |
} | |
print_test_recipe(){ | |
echo "#!/bin/bash" | |
echo | |
echo testname="$(show_string_var testname)" | |
echo command="$(show_string_var command)" | |
echo args="$(show_list "${args[@]}")" | |
echo exit_code="$(show_string_var exit_code)" | |
patch_filename_vars stdin_file stdout_file stderr_file | |
echo stdin_file="$(show_string_var stdin_file)" | |
echo stdout_file="$(show_string_var stdout_file)" | |
echo stderr_file="$(show_string_var stderr_file)" | |
echo sources="$(show_list "${sources[@]}")" | |
echo products="$(show_list "${products[@]}")" | |
echo | |
echo "# Environment variables:" | |
echo env_vars="$(show_list "${env_vars[@]}")" | |
show_vars env_var_ "${env_vars[@]}" | |
echo | |
print_function setup \ | |
: Perform here actions to be run before the tested program | |
echo | |
print_function munge \ | |
: Munge here the results of the tested program to ease the check | |
echo | |
print_function check \ | |
check_exit_code \&\& \;\ | |
check_stderr \&\& \;\ | |
check_stdout \&\& \;\ | |
check_products \&\& \;\ | |
: Perform here extra checks on the tested program | |
echo | |
print_function explain \ | |
explain_exit_code \;\ | |
explain_stdout \;\ | |
explain_stderr \;\ | |
explain_products \;\ | |
: Explain here more potential differences | |
echo | |
print_function teardown \ | |
: Undo here the actions of setup | |
} | |
clear_recipe_scope(){ | |
unset testname command args exit_code stdin_file stdout_file stderr_file \ | |
env_vars sources products | |
# here one does clear the env_var_... | |
unset -f setup munge check explain teardown | |
} | |
# NO LONGER USED | |
# export_env env_var_ PATH USER ... | |
# | |
# expands to: | |
# | |
# export PATH=$env_var_PATH | |
# export USER=$env_var_USER | |
# ... | |
#export_env(){ | |
# prefix="$1" | |
# shift | |
# for var; do | |
# export $var="$(get_var $prefix$var)" | |
# done | |
#} | |
with_env_vars(){ | |
prefix="$1" | |
shift | |
env_defs=( ) | |
while [ "$#" -gt 0 ]; do | |
var="$1" | |
[ $var != -- ] || break | |
shift | |
env_defs=("${env_defs[@]}" $var="$(get_var $prefix$var)") | |
done | |
[ "$1" = -- ] || internal_error with_env_vars: -- was expected | |
shift | |
# NOTE | |
# * That's too sad that env does not support `--' | |
# * There seems to be a special case for PATH, that this leading PATH= is | |
# fixing. | |
log 1 "Running:" "$@" | |
env -i PATH= "${env_defs[@]}" "$@" | |
} | |
record(){ | |
# NOTE Should we check that std{in,err,out}_file are paths | |
# for the current directory (no / in there)? | |
local full_command=$(which "$command") | |
tee "$stdin_file" | | |
with_env_vars '' "${env_vars[@]}" -- \ | |
"${chroot_args[@]}" \ | |
"$full_command" "${args[@]}" \ | |
>"$stdout_file" 2>"$stderr_file" | |
# Here it is important that the exit code we get is the | |
# of the command and not the one of tee, but shells (Zsh and | |
# bash) seems to behave this way. | |
exit_code="$?" | |
print_test_recipe | |
} | |
assert_non_dirs(){ | |
for assert_non_dirs_file; do | |
local file="$assert_non_dirs_file" | |
[ ! -d "$file" ] || error "\`$file' is a directory, list each files of the directory instead" | |
# Excluding all non-regular files also excludes /dev/null | |
# [ -f "$file" ] || error "\`$file' should be a regular file" | |
done | |
} | |
cmp_files(){ | |
assert_non_dirs "$1" "$2" | |
cmp -s "$1" "$2" | |
} | |
diff_files(){ | |
if cmp_files "$2" "$3"; then | |
: | |
else | |
echo "$1" | |
# | color-diff | |
diff -u --label 'ACTUAL' --label 'REFERENCE' "$2" "$3" | nest2 | |
fi | |
} | |
diff_strings(){ | |
if [ "$2" != "$3" ]; then | |
echo "$1" | |
{ | |
echo "-$2" | |
echo "+$3" | |
} | nest2 # | color-diff | |
fi | |
} | |
check_stderr(){ | |
cmp -s "$stderr_file" "$my_stderr_file" | |
} | |
check_stdout(){ | |
cmp -s "$stdout_file" "$my_stdout_file" | |
} | |
#iter2(){ | |
# local i=0 | |
# local f=$(eval '$'$1) | |
# local xs=$(eval '$'$2) | |
# local ys=$(eval '$'$3) | |
# for x in "${xs[@]}"; do | |
# f "$x" "${ys[$i]}" | |
# done | |
#} | |
# | |
#check_product(){ | |
# if cmp -s "$ref_prod" "${my_products[$i]}"; then | |
# : | |
# else | |
# res=0 | |
# break | |
# fi | |
#} | |
check_products(){ | |
local i=0 | |
local res=1 | |
# iter2 products my_products check_product | |
for ref_prod in "${products[@]}"; do | |
if cmp_files "$ref_prod" "${my_products[$i]}"; then | |
: | |
else | |
res=0 | |
break | |
fi | |
i=$((i + 1)) | |
done | |
(( res )) | |
} | |
check_exit_code(){ | |
[ "$exit_code" = "$my_exit_code" ] | |
} | |
explain_stderr(){ | |
diff_files "$testname: Standard error output" "$my_stderr_file" "$stderr_file" | |
} | |
explain_stdout(){ | |
diff_files "$testname: Standard output" "$my_stdout_file" "$stdout_file" | |
} | |
explain_exit_code(){ | |
diff_strings "$testname: Exit code" "$my_exit_code" "$exit_code" | |
} | |
explain_products(){ | |
local i=0 | |
for ref_prod in "${products[@]}"; do | |
diff_files "$testname: Product file \`$ref_prod'" "${my_products[$i]}" "$ref_prod" | |
i=$((i + 1)) | |
done | |
(( res )) | |
} | |
cmp_recipes_ref_part(){ | |
clear_recipe_scope | |
. TESTRECIPE | |
if check; then | |
echo -e "$CR$(green PASS): $testname" >>"$log_file" | |
else | |
echo -e "$CR$(red FAIL): $testname" >>"$log_file" | |
false | |
fi | |
} | |
#destpath(){ | |
#} | |
cmp_recipes_my_part(){ | |
clear_recipe_scope | |
. TESTRECIPE | |
my_exit_code="$exit_code" | |
my_stdout_file="$(readlink -f "$stdout_file")" | |
my_stderr_file="$(readlink -f "$stderr_file")" | |
my_products=( ) | |
for p in "${products[@]}"; do | |
my_products=("${my_products[@]}" "$(readlink -f "$p")") | |
done | |
} | |
cmp_recipes(){ | |
ref_recipe="$1" | |
my_recipe="$2" | |
# One loads 'my' before 'ref', to avoid 'my' being able to overwrite | |
# the work done by 'ref' | |
within_dir "$my_recipe" cmp_recipes_my_part | |
within_dir "$ref_recipe" cmp_recipes_ref_part | |
} | |
usage(){ | |
cat_info <<EOF | |
Usage: $me [<option>*] <testname>.t* | |
option ::= --batch | |
EOF | |
echo error: "$@" >>"$log_file" | |
exit 1 | |
} | |
check_valid_var(){ | |
case "$1" in | |
*[^a-zA-Z0-9_]*) internal_error "Illegal variable: \`$1'";; | |
esac | |
echo -n "$1" | |
} | |
member(){ | |
local x="$1" | |
shift | |
mem=0 | |
for y; do | |
if [ "$x" = "$y" ]; then | |
mem=1 | |
break | |
fi | |
done | |
(( mem )) | |
} | |
length(){ | |
echo $# | |
} | |
null(){ | |
[ $# = 0 ] | |
} | |
within_dir(){ | |
local old="$(pwd)" | |
cd "$1" | |
shift | |
local cur="$(pwd)" | |
log_w "Entering directory \`$cur'" | |
if "$@"; then | |
log_w "Leaving directory \`$cur'" | |
cd "$old" | |
else | |
log_w "Leaving directory \`$cur'" | |
cd "$old" | |
false | |
fi | |
} | |
display_files(){ | |
local title="$1" | |
shift | |
for display_files_file; do | |
local file="$display_files_file" | |
info "$title file \`$file':" | |
nest2_info "$file" | |
done | |
} | |
copy_file(){ | |
local src="$1" | |
local dst="$2" | |
assert_non_dirs "$src" | |
if [ -e "$dst" ]; then | |
assert_non_dirs "$dst" | |
if cmp -s "$src" "$dst"; then | |
: Nothing to do | |
else | |
if asking "\`$dst' already exists, overwrite?"; then | |
cp -a "$src" "$dst" | |
else | |
error "\`$dst' already exists" | |
fi | |
fi | |
else | |
mkdir -p "$(dirname "$dst")" | |
cp -a "$src" "$dst" | |
fi | |
} | |
check_action_mydir(){ | |
local full_command=$(which "$command") | |
stdout_file=stdout | |
stderr_file=stderr | |
setup | |
set +e | |
with_env_vars env_var_ "${env_vars[@]}" -- \ | |
"${chroot_args[@]}" \ | |
"$full_command" "${args[@]}" \ | |
<"$stdin_file" >"$stdout_file" 2>"$stderr_file" | |
exit_code="$?" | |
set -e | |
copy_file "$stdin_file" "$raw_stdin_file" | |
stdin_file="$raw_stdin_file" | |
print_test_recipe >TESTRECIPE | |
munge | |
} | |
copy_source_or_product(){ | |
local raw_file="$1" | |
local src="$(readlink -f "$2")/" | |
local dst="$3" | |
if [ -e "$raw_file" ]; then | |
local abs_file="$(readlink -f "$raw_file")" | |
local file="${abs_file#$src}" | |
if [ "$file" = "$abs_file" ]; then | |
# $file is an absolute path outside of the current directory. | |
# | |
# CHROOT: issue or solution | |
# If the program is gonna use this file with this absolute path | |
# we can't do anything without a chroot. In the mean time we | |
# could backup it and restore it later. | |
echo "$abs_file" | |
else | |
# OK, just a relative path, let's simply import it | |
local dstfile="$dst/$file" | |
copy_file "$abs_file" "$dstfile" | |
echo "$file" | |
fi | |
else | |
case "$raw_file" in | |
*://*) | |
error "URLs are not supported yet in sources";; | |
*) | |
error "The source file \`$raw_file' does not exist";; | |
esac | |
fi | |
} | |
copy_sources(){ | |
local cwd="$1" | |
local dstdir="$2" | |
local tmp_sources=("${sources[@]}") | |
sources=( ) | |
for copy_sources_file in "${tmp_sources[@]}"; do | |
sources=("${sources[@]}" | |
"$(copy_source_or_product "$copy_sources_file" "$cwd" "$dstdir")") | |
done | |
} | |
copy_products(){ | |
local cwd="$1" | |
local dstdir="$2" | |
local tmp_products=("${products[@]}") | |
products=( ) | |
for copy_products_file in "${tmp_products[@]}"; do | |
products=("${products[@]}" | |
"$(copy_source_or_product "$copy_products_file" "$cwd" "$dstdir")") | |
done | |
} | |
check_action_refdir(){ | |
local mydir="$1" | |
local cwd="$(pwd)" | |
[ -e TESTRECIPE ] || usage "$cwd" is not a valid test directory, no TESTRECIPE found | |
clear_recipe_scope | |
. TESTRECIPE | |
[ -n "$testname" ] || usage bad TESTRECIPE, no testname variable | |
local cmdtestbasename="$(basename "$cwd")" | |
[ "$cmdtestbasename" = "$testname" ] || | |
usage "wrong TESTRECIPE, expected \`$cmdtestbasename' found \`$testname'" | |
raw_stdin_file="$stdin_file" | |
stdin_file="$(readlink -f "$stdin_file")" | |
copy_sources "$cwd" "$mydir" | |
} | |
check_action(){ | |
while [ "$#" -gt 0 ]; do | |
case "$1" in | |
--batch) shift; batch_mode=1;; | |
*) break | |
esac | |
done | |
[ "$#" -gt 0 ] || usage The test name was expected | |
local failed_tests=( ) | |
local testcount=$# | |
for check_action_refdir; do | |
local refdir="$check_action_refdir" | |
echo -n "$refdir..." >>"$log_file" | |
local mydir="${refdir%.t}".my | |
ensure_empty_dir -f "$mydir" | |
within_dir "$refdir" check_action_refdir "$(readlink -f "$mydir")" | |
within_dir "$mydir" check_action_mydir | |
if cmp_recipes "$refdir" "$mydir"; then | |
within_dir "$mydir" teardown | |
else | |
failed_tests=("${failed_tests[@]}" "$testname") | |
within_dir "$refdir" explain >>"$log_file" | |
within_dir "$mydir" teardown | |
fi | |
# Even if in basic cases refdir is equivalent to mydir, in | |
# general this is not the case. For instance functions like | |
# setup, check and teardown are reset to their default version. | |
# Dynamic variables definition will be in-lined: | |
# env_var_PATH="$PATH" | |
# becomes: | |
# env_var_PATH='/usr/bin:...' | |
# bakdir="$(dirname "$testname")/${testname%.t}".bak | |
# [ ! -d "$bakdir" ] || rm -r "$bakdir" | |
# mv "$refdir" "$bakdir" | |
# mv "$mydir" "$refdir" | |
done | |
if null "${failed_tests[@]}"; then | |
echo "All $testcount tests $(green PASSED)" >>"$log_file" | |
else | |
echo "$(length "${failed_tests[@]}") out of $testcount tests $(red FAILED)" >>"$log_file" | |
exit 2 | |
fi | |
} | |
[ "$me" != cmdcheck ] || check_action "$@" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash -e | |
. "$(which cmdcheck)" | |
me=$(basename "$0") | |
assert_non_dirs(){ | |
for assert_non_dirs_file; do | |
local file="$assert_non_dirs_file" | |
[ ! -d "$file" ] || error "\`$file' is a directory, list each files of the directory instead" | |
# Excluding all non-regular files also excludes /dev/null | |
# [ -f "$file" ] || error "\`$file' should be a regular file" | |
done | |
} | |
usage(){ | |
cat_info <<EOF | |
Usage: $me <testname>.t <option>* -- <cmd> <cmd-arg>* | |
option ::= --batch | |
| -f | --force | |
| --no-stdin | |
| --env <env-option> | |
| --source <file> | |
| --product <file> | |
env-option ::= '' | |
| <env-option>,<env-option> | |
| empty | |
| copy:<VAR> | |
| pass:<VAR> | |
| <VAR>=<VAL> | |
| copy | |
| pass | |
EOF | |
echo error: "$@" >>"$log_file" | |
exit 1 | |
} | |
parse_env_option(){ | |
# *,* and *=* can interacts badly | |
case "$1" in | |
'') : ;; | |
*,*) parse_env_option "$(echo "$1" | cut -d, -f1)" | |
parse_env_option "$(echo "$1" | cut -d, -f2-)";; | |
empty) env_vars=( );; | |
copy:*) local var=$(check_valid_var "$(echo "$1" | cut -d: -f2-)") | |
env_vars=("${env_vars[@]}" $var);; | |
pass:*) local var=$(check_valid_var "$(echo "$1" | cut -d: -f2-)") | |
member $var "${env_vars[@]}" || env_vars=("${env_vars[@]}" $var) | |
eval pass_env_var_$var=1;; | |
*=*) local var=$(check_valid_var "$(echo "$1" | cut -d= -f1)") | |
member $var "${env_vars[@]}" || env_vars=("${env_vars[@]}" $var) | |
eval env_var_"$1";; | |
copy) for var in ${all_env_vars[@]}; do | |
member $var "${env_vars[@]}" || env_vars=("${env_vars[@]}" $var) | |
eval env_var_$var=$(show_string_var $var) | |
done;; | |
pass) for var in ${all_env_vars[@]}; do | |
member $var "${env_vars[@]}" || env_vars=("${env_vars[@]}" $var) | |
eval pass_env_var_$var=1 | |
done;; | |
*) usage Unexpected env-option \`$1\';; | |
esac | |
} | |
record_verbose(){ | |
record >TESTRECIPE <"$stdin" | |
info Exit code: "$exit_code" | |
info Standard output: | |
nest2_info "$stdout_file" | |
info Standard error output: | |
nest2_info "$stderr_file" | |
display_files Source "${sources[@]}" | |
display_files Product "${products[@]}" | |
info Generated test recipe: | |
nest2_info TESTRECIPE | |
} | |
record_action(){ | |
stdin=/dev/stdin | |
local eed_opts=( ) | |
local all_env_vars=( ) | |
for var in $(env | cut -d= -f1); do | |
all_env_vars=("${all_env_vars[@]}" $(check_valid_var "$var")) | |
done | |
env_vars=( ) | |
for var in ${all_env_vars[@]}; do | |
member $var "${env_vars[@]}" || env_vars=("${env_vars[@]}" $var) | |
eval env_var_$var=$(show_string_var $var) | |
done | |
stdin_file=stdin | |
stdout_file=stdout | |
stderr_file=stderr | |
sources=( ) | |
products=( ) | |
exit_code=0 | |
[ "$#" -gt 0 ] || usage The test name was expected | |
case "$1" in | |
*.t) dir="$1";; | |
*) usage The test name was expected to end with \`.t\' | |
esac | |
shift | |
while [ "$#" -gt 0 ]; do | |
case "$1" in | |
--) shift; break;; | |
--batch) shift; batch_mode=1;; | |
-f|--force) shift; eed_opts=(-f);; | |
--no-stdin) shift; stdin=/dev/null; stdin_file=/dev/null;; | |
--env) shift; parse_env_option "$1"; shift;; | |
--source) shift; sources=("${sources[@]}" "$1"); shift;; | |
--product) shift; products=("${products[@]}" "$1"); shift;; | |
*) usage Unexpected record-option \`$1\' | |
esac | |
done | |
[ "$#" -gt 0 ] || usage A command was expected | |
command="$1" | |
shift | |
args=("$@") | |
testname="$(basename "$dir")" | |
ensure_empty_dir "${eed_opts[@]}" "$dir" | |
copy_products . "$dir" | |
copy_sources . "$dir" | |
[ "$stdin" = /dev/null ] || info Reading from stdin... | |
within_dir "$dir" record_verbose | |
} | |
[ "$me" != cmdrecord ] || record_action "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment