From 5cef71dc6c640db414c41f59a5016fd3f5326bf9 Mon Sep 17 00:00:00 2001 From: Eric Wong Date: Wed, 17 Feb 2010 18:53:52 -0800 Subject: tests: import basic TAP library from Rainbows! --- GNUmakefile | 2 + t/.gitignore | 2 + t/GNUmakefile | 67 +++++++++++++++++ t/README | 42 +++++++++++ t/bin/content-md5-put | 36 +++++++++ t/bin/sha1sum.rb | 23 ++++++ t/bin/unused_listen | 40 ++++++++++ t/bin/utee | 12 +++ t/env.ru | 3 + t/my-tap-lib.sh | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++ t/t0000-http-basic.sh | 50 +++++++++++++ t/test-lib.sh | 100 +++++++++++++++++++++++++ 12 files changed, 577 insertions(+) create mode 100644 t/.gitignore create mode 100644 t/GNUmakefile create mode 100644 t/README create mode 100755 t/bin/content-md5-put create mode 100755 t/bin/sha1sum.rb create mode 100755 t/bin/unused_listen create mode 100755 t/bin/utee create mode 100644 t/env.ru create mode 100644 t/my-tap-lib.sh create mode 100755 t/t0000-http-basic.sh create mode 100644 t/test-lib.sh diff --git a/GNUmakefile b/GNUmakefile index 3326aff..70707ce 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -58,6 +58,7 @@ lib/unicorn_http.$(DLEXT): $(ext)/unicorn_http.$(DLEXT) install -m644 $< $@ http: lib/unicorn_http.$(DLEXT) +test-install: $(test_prefix)/.stamp $(test_prefix)/.stamp: $(inst_deps) mkdir -p $(test_prefix)/.ccache tar cf - $(inst_deps) GIT-VERSION-GEN | \ @@ -282,3 +283,4 @@ gem install-gem: GIT-VERSION-FILE endif .PHONY: .FORCE-GIT-VERSION-FILE doc $(T) $(slow_tests) manifest man +.PHONY: test-install diff --git a/t/.gitignore b/t/.gitignore new file mode 100644 index 0000000..a0c1c36 --- /dev/null +++ b/t/.gitignore @@ -0,0 +1,2 @@ +/random_blob +/.dep+* diff --git a/t/GNUmakefile b/t/GNUmakefile new file mode 100644 index 0000000..20e5b6e --- /dev/null +++ b/t/GNUmakefile @@ -0,0 +1,67 @@ +# we can run tests in parallel with GNU make +all:: + +pid := $(shell echo $$PPID) + +RUBY = ruby +-include ../local.mk +ifeq ($(RUBY_VERSION),) + RUBY_VERSION := $(shell $(RUBY) -e 'puts RUBY_VERSION') +endif + +ifeq ($(RUBY_VERSION),) + $(error unable to detect RUBY_VERSION) +endif + +T = $(wildcard t[0-9][0-9][0-9][0-9]-*.sh) + +all:: $(T) + +# can't rely on "set -o pipefail" since we don't require bash or ksh93 :< +t_pfx = trash/$@-$(RUBY_VERSION) +TEST_OPTS = +# TRACER = strace -f -o $(t_pfx).strace -s 100000 +# TRACER = /usr/bin/time -o $(t_pfx).time + +ifdef V + ifeq ($(V),2) + TEST_OPTS += --trace + else + TEST_OPTS += --verbose + endif +endif + +random_blob: + dd if=/dev/urandom bs=1M count=30 of=$@.$(pid) + mv $@.$(pid) $@ + +$(T): random_blob + +dependencies := socat curl +deps := $(addprefix .dep+,$(dependencies)) +$(deps): dep_bin = $(lastword $(subst +, ,$@)) +$(deps): + @which $(dep_bin) > $@.$(pid) 2>/dev/null || : + @test -s $@.$(pid) || \ + { echo >&2 "E '$(dep_bin)' not found in PATH=$(PATH)"; exit 1; } + @mv $@.$(pid) $@ +dep: $(deps) + +test_prefix := $(CURDIR)/../test/install-$(RUBY_VERSION) +$(test_prefix)/.stamp: + $(MAKE) -C .. test-install + +$(T): export RUBY := $(RUBY) +$(T): export PATH := $(test_prefix)/bin:$(PATH) +$(T): export RUBYLIB := $(test_prefix)/lib:$(RUBYLIB) +$(T): dep $(test_prefix)/.stamp trash/.gitignore + $(TRACER) $(SHELL) $(SH_TEST_OPTS) $@ $(TEST_OPTS) + +trash/.gitignore: + mkdir -p $(@D) + echo '*' > $@ + +clean: + $(RM) -r trash/* + +.PHONY: $(T) clean diff --git a/t/README b/t/README new file mode 100644 index 0000000..095f106 --- /dev/null +++ b/t/README @@ -0,0 +1,42 @@ += Unicorn integration test suite + +These are all integration tests that start the server on random, unused +TCP ports or Unix domain sockets. They're all designed to run +concurrently with other tests to minimize test time, but tests may be +run independently as well. + +We write our tests in Bourne shell because that's what we're +comfortable writing integration tests with. + +== Requirements + +* {Ruby 1.8 or 1.9}[http://www.ruby-lang.org/] (duh!) +* {GNU make}[http://www.gnu.org/software/make/] +* {socat}[http://www.dest-unreach.org/socat/] +* {curl}[http://curl.haxx.se/] +* standard UNIX shell utilities (Bourne sh, awk, sed, grep, ...) + +We do not use bashisms or any non-portable, non-POSIX constructs +in our shell code. We use the "pipefail" option if available and +mainly test with {ksh}[http://kornshell.com/], but occasionally +with {dash}[http://gondor.apana.org.au/~herbert/dash/] and +{bash}[http://www.gnu.org/software/bash/], too. + +== Running Tests + +To run the entire test suite with 8 tests running at once: + + make -j8 + +To run one individual test: + + make t0000-simple-http.sh + +You may also increase verbosity by setting the "V" variable for +GNU make. To disable trapping of stdout/stderr: + + make V=1 + +To enable the "set -x" option in shell scripts to trace execution + + make V=2 diff --git a/t/bin/content-md5-put b/t/bin/content-md5-put new file mode 100755 index 0000000..01da0bb --- /dev/null +++ b/t/bin/content-md5-put @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby +# -*- encoding: binary -*- +# simple chunked HTTP PUT request generator (and just that), +# it reads stdin and writes to stdout so socat can write to a +# UNIX or TCP socket (or to another filter or file) along with +# a Content-MD5 trailer. +require 'digest/md5' +$stdout.sync = $stderr.sync = true +$stdout.binmode +$stdin.binmode + +bs = ENV['bs'] ? ENV['bs'].to_i : 4096 + +if ARGV.grep("--no-headers").empty? + $stdout.write( + "PUT / HTTP/1.1\r\n" \ + "Host: example.com\r\n" \ + "Transfer-Encoding: chunked\r\n" \ + "Trailer: Content-MD5\r\n" \ + "\r\n" + ) +end + +digest = Digest::MD5.new +if buf = $stdin.readpartial(bs) + begin + digest.update(buf) + $stdout.write("%x\r\n" % [ buf.size ]) + $stdout.write(buf) + $stdout.write("\r\n") + end while $stdin.read(bs, buf) +end + +digest = [ digest.digest ].pack('m').strip +$stdout.write("0\r\n") +$stdout.write("Content-MD5: #{digest}\r\n\r\n") diff --git a/t/bin/sha1sum.rb b/t/bin/sha1sum.rb new file mode 100755 index 0000000..b602e79 --- /dev/null +++ b/t/bin/sha1sum.rb @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby +# -*- encoding: binary -*- + +# Reads from stdin and outputs the SHA1 hex digest of the input this is +# ONLY used as a last resort, our test code will try to use sha1sum(1), +# openssl(1), or gsha1sum(1) before falling back to using this. We try +# all options first because we have a strong and healthy distrust of our +# Ruby abilities in general, and *especially* when it comes to +# understanding (and trusting the implementation of) Ruby 1.9 encoding. + +require 'digest/sha1' +$stdout.sync = $stderr.sync = true +$stdout.binmode +$stdin.binmode +bs = 16384 +digest = Digest::SHA1.new +if buf = $stdin.read(bs) + begin + digest.update(buf) + end while $stdin.read(bs, buf) +end + +$stdout.syswrite("#{digest.hexdigest}\n") diff --git a/t/bin/unused_listen b/t/bin/unused_listen new file mode 100755 index 0000000..b638f54 --- /dev/null +++ b/t/bin/unused_listen @@ -0,0 +1,40 @@ +#!/usr/bin/env ruby +# -*- encoding: binary -*- +# this is to remain compatible with the unused_port function in the +# Unicorn test/test_helper.rb file +require 'socket' +require 'tmpdir' + +default_port = 8080 +addr = ENV['UNICORN_TEST_ADDR'] || '127.0.0.1' +retries = 100 +base = 5000 +port = sock = lock_path = nil + +begin + begin + port = base + rand(32768 - base) + while port == default_port + port = base + rand(32768 - base) + end + + sock = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0) + sock.bind(Socket.pack_sockaddr_in(port, addr)) + sock.listen(5) + rescue Errno::EADDRINUSE, Errno::EACCES + sock.close rescue nil + retry if (retries -= 1) >= 0 + end + + # since we'll end up closing the random port we just got, there's a race + # condition could allow the random port we just chose to reselect itself + # when running tests in parallel with gmake. Create a lock file while + # we have the port here to ensure that does not happen. + lock_path = "#{Dir::tmpdir}/unicorn_test.#{addr}:#{port}.lock" + lock = File.open(lock_path, File::WRONLY|File::CREAT|File::EXCL, 0600) +rescue Errno::EEXIST + sock.close rescue nil + retry +end +sock.close rescue nil +puts %Q(listen=#{addr}:#{port} T_RM_LIST="$T_RM_LIST #{lock_path}") diff --git a/t/bin/utee b/t/bin/utee new file mode 100755 index 0000000..7b61fea --- /dev/null +++ b/t/bin/utee @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby +# -*- encoding: binary -*- +# tee(1) as distributed on most(all?) systems is buffered in luserspace +# this only does unbuffered writes (with line-buffered input) to make +# test output appear in real-time +$stdin.binmode +$stdout.binmode +fp = File.open(ARGV.shift, "wb") +$stdin.each_line do |line| + fp.syswrite line + $stdout.syswrite line +end diff --git a/t/env.ru b/t/env.ru new file mode 100644 index 0000000..388412e --- /dev/null +++ b/t/env.ru @@ -0,0 +1,3 @@ +use Rack::ContentLength +use Rack::ContentType, "text/plain" +run lambda { |env| [ 200, {}, [ env.inspect << "\n" ] ] } diff --git a/t/my-tap-lib.sh b/t/my-tap-lib.sh new file mode 100644 index 0000000..2d0c1a9 --- /dev/null +++ b/t/my-tap-lib.sh @@ -0,0 +1,200 @@ +#!/bin/sh +# Copyright (c) 2009, 2010 Eric Wong +# +# TAP-producing shell library for POSIX-compliant Bourne shells We do +# not _rely_ on Bourne Again features, though we will use "set -o +# pipefail" from ksh93 or bash 3 if available +# +# Only generic, non-project/non-language-specific stuff goes here. We +# only have POSIX dependencies for the core tests (without --verbose), +# though we'll enable useful non-POSIX things if they're available. +# +# This test library is intentionally unforgiving, it does not support +# skipping tests nor continuing after any failure. Any failures +# immediately halt execution as do any references to undefined +# variables. +# +# When --verbose is specified, we always prefix stdout/stderr +# output with "#" to avoid confusing TAP consumers. Otherwise +# the normal stdout/stderr streams are redirected to /dev/null + +# dup normal stdout(fd=1) and stderr (fd=2) to fd=3 and fd=4 respectively +# normal TAP output goes to fd=3, nothing should go to fd=4 +exec 3>&1 4>&2 + +# ensure a sane environment +TZ=UTC LC_ALL=C LANG=C +export LANG LC_ALL TZ +unset CDPATH + +# pipefail is non-POSIX, but very useful in ksh93/bash +( set -o pipefail 2>/dev/null ) && set -o pipefail + +SED=${SED-sed} + +# Unlike other test frameworks, we are unforgiving and bail immediately +# on any failures. We do this because we're lazy about error handling +# and also because we believe anything broken should not be allowed to +# propagate throughout the rest of the test +set -e +set -u + +# name of our test +T=${0##*/} + +t_expect_nr=-1 +t_nr=0 +t_current= +t_complete=false + +# list of files to remove unconditionally on exit +T_RM_LIST= + +# list of files to remove only on successful exit +T_OK_RM_LIST= + +# emit output to stdout, it'll be parsed by the TAP consumer +# so it must be TAP-compliant output +t_echo () { + echo >&3 "$@" +} + +# emits non-parsed information to stdout, it will be prefixed with a '#' +# to not throw off TAP consumers +t_info () { + t_echo '#' "$@" +} + +# exit with an error and print a diagnostic +die () { + echo >&2 "$@" + exit 1 +} + +# our at_exit handler, it'll fire for all exits except SIGKILL (unavoidable) +t_at_exit () { + code=$? + set +e + if test $code -eq 0 + then + $t_complete || { + t_info "t_done not called" + code=1 + } + elif test -n "$t_current" + then + t_echo "not ok $t_nr - $t_current" + fi + if test $t_expect_nr -ne -1 + then + test $t_expect_nr -eq $t_nr || { + t_info "planned $t_expect_nr tests but ran $t_nr" + test $code -ne 0 || code=1 + } + fi + $t_complete || { + t_info "unexpected test failure" + test $code -ne 0 || code=1 + } + rm -f $T_RM_LIST + test $code -eq 0 && rm -f $T_OK_RM_LIST + set +x + exec >&3 2>&4 + t_close_fds + exit $code +} + +# close test-specific extra file descriptors +t_close_fds () { + exec 3>&- 4>&- +} + +# call this at the start of your test to specify the number of tests +# you plan to run +t_plan () { + test "$1" -ge 1 || die "must plan at least one test" + test $t_expect_nr -eq -1 || die "tried to plan twice in one test" + t_expect_nr=$1 + shift + t_echo 1..$t_expect_nr "#" "$@" + trap t_at_exit EXIT +} + +_t_checkup () { + test $t_expect_nr -le 0 && die "no tests planned" + test -n "$t_current" && t_echo "ok $t_nr - $t_current" + true +} + +# finalizes any previously test and starts a new one +t_begin () { + _t_checkup + t_nr=$(( $t_nr + 1 )) + t_current="$1" + + # just in case somebody wanted to cheat us: + set -e +} + +# finalizes the current test without starting a new one +t_end () { + _t_checkup + t_current= +} + +# run this to signify the end of your test +t_done () { + _t_checkup + t_current= + t_complete=true + test $t_expect_nr -eq $t_nr || exit 1 + exit 0 +} + +# create and assign named-pipes to variable _names_ passed to this function +t_fifos () { + for _id in "$@" + do + _name=$_id + _tmp=$(mktemp -t $T.$$.$_id.XXXXXXXX) + eval "$_id=$_tmp" + rm -f $_tmp + mkfifo $_tmp + T_RM_LIST="$T_RM_LIST $_tmp" + done +} + +t_verbose=false t_trace=false + +while test "$#" -ne 0 +do + arg="$1" + shift + case $arg in + -v|--verbose) t_verbose=true ;; + --trace) t_trace=true t_verbose=true ;; + *) die "Unknown option: $arg" ;; + esac +done + +# we always only setup stdout, nothing should end up in the "real" stderr +if $t_verbose +then + if test x"$(which mktemp 2>/dev/null)" = x + then + die "mktemp(1) not available for --verbose" + fi + t_fifos t_stdout t_stderr + + ( + # use a subshell so seds are not waitable + $SED -e 's/^/#: /' $t_stdout & + $SED -e 's/^/#! /' $t_stderr & + ) & + exec > $t_stdout 2> $t_stderr +else + exec > /dev/null 2> /dev/null +fi + +$t_trace && set -x +true diff --git a/t/t0000-http-basic.sh b/t/t0000-http-basic.sh new file mode 100755 index 0000000..01ead95 --- /dev/null +++ b/t/t0000-http-basic.sh @@ -0,0 +1,50 @@ +#!/bin/sh +. ./test-lib.sh +t_plan 8 "simple HTTP connection tests" + +t_begin "setup and start" && { + unicorn_setup + unicorn -D -c $unicorn_config env.ru + unicorn_wait_start +} + +t_begin "single request" && { + curl -sSfv http://$listen/ +} + +t_begin "check stderr has no errors" && { + check_stderr +} + +t_begin "HTTP/0.9 request should not return headers" && { + ( + printf 'GET /\r\n' + cat $fifo > $tmp & + wait + echo ok > $ok + ) | socat - TCP:$listen > $fifo +} + +t_begin "env.inspect should've put everything on one line" && { + test 1 -eq $(wc -l < $tmp) +} + +t_begin "no headers in output" && { + if grep ^Connection: $tmp + then + die "Connection header found in $tmp" + elif grep ^HTTP/ $tmp + then + die "HTTP/ found in $tmp" + fi +} + +t_begin "killing succeeds" && { + kill $unicorn_pid +} + +t_begin "check stderr has no errors" && { + check_stderr +} + +t_done diff --git a/t/test-lib.sh b/t/test-lib.sh new file mode 100644 index 0000000..60ace97 --- /dev/null +++ b/t/test-lib.sh @@ -0,0 +1,100 @@ +#!/bin/sh +# Copyright (c) 2009 Rainbows! hackers +# Copyright (c) 2010 Unicorn hackers +. ./my-tap-lib.sh + +set +u +set -e +RUBY="${RUBY-ruby}" +RUBY_VERSION=${RUBY_VERSION-$($RUBY -e 'puts RUBY_VERSION')} +t_pfx=$PWD/trash/$T-$RUBY_VERSION +set -u + +PATH=$PWD/bin:$PATH +export PATH + +test -x $PWD/bin/unused_listen || die "must be run in 't' directory" + +wait_for_pid () { + path="$1" + nr=30 + while ! test -s "$path" && test $nr -gt 0 + do + nr=$(($nr - 1)) + sleep 1 + done +} + +# given a list of variable names, create temporary files and assign +# the pathnames to those variables +rtmpfiles () { + for id in "$@" + do + name=$id + _tmp=$t_pfx.$id + eval "$id=$_tmp" + + case $name in + *fifo) + rm -f $_tmp + mkfifo $_tmp + T_RM_LIST="$T_RM_LIST $_tmp" + ;; + *socket) + rm -f $_tmp + T_RM_LIST="$T_RM_LIST $_tmp" + ;; + *) + > $_tmp + T_OK_RM_LIST="$T_OK_RM_LIST $_tmp" + ;; + esac + done +} + +dbgcat () { + id=$1 + eval '_file=$'$id + echo "==> $id <==" + sed -e "s/^/$id:/" < $_file +} + +check_stderr () { + set +u + _r_err=${1-${r_err}} + set -u + if grep -i Error $_r_err + then + die "Errors found in $_r_err" + elif grep SIGKILL $_r_err + then + die "SIGKILL found in $_r_err" + fi +} + +# unicorn_setup +unicorn_setup () { + eval $(unused_listen) + rtmpfiles unicorn_config pid r_err r_out fifo tmp ok + cat > $unicorn_config <