#! /usr/bin/perl -w use Symbol 'qualify_to_ref'; use IO::Handle; use Errno; use POSIX ":sys_wait_h"; use MIME::Base64; use Time::HiRes qw(gettimeofday); no locale; use bytes; require 5.006; ($preserve_temporaries, $expand_mode, $verbose) = (0, 0, 0); $running_pid = 0; %require_error_commands = (); $quiet_ebadf = 0; ## utilities sub index2 ($$;$) { my($result) = (defined($_[2]) ? index($_[0], $_[1], $_[2]) : index($_[0], $_[1])); $result = length $_[0] if $result < 0; $result; } sub shquote ($) { my($t) = @_; $t =~ s/\'/\'\"\'\"\'/g; "'$t'"; } sub min (@) { my($m) = pop @_; foreach my $mm (@_) { $m = $mm if $mm < $m; } $m; } ## testie ipc sub tipc_write ($$;$$) { my($fh, $command, $arg, $noflush) = @_; die "!" if $command !~ /\A[A-Z]\z/; $arg = "" if !defined($arg); # print STDERR "$$ write $command $arg\n"; print $fh $command, length($arg), " ", $arg, "\n"; $fh->flush if !$noflush; } sub tipc_error () { if ($! == 0 || $!{EINTR} || $!{EAGAIN} || $!{EWOULDBLOCK}) { return; } elsif ($!{EBADF} && $::quiet_ebadf) { exit(0); } else { die "testie: ipc error: $!"; } } sub tipc_read ($$) { my($fh, $bufref) = @_; my($n, $x); while (1) { # does the buffer contain a valid command? if ($$bufref =~ /\A(\s*([A-Z])(\d+) )/ && length($$bufref) >= length($1) + $3) { my($v) = substr($$bufref, length($1), $3); $$bufref = substr($$bufref, length($1) + $3); return ($2, $v); } # if not try to read more data $x = ""; $n = sysread($fh, $x, 4096); tipc_error if !defined($n); return () if !$n; $$bufref .= $x; } } ## testie error handler object package TestieErrorHandler; sub new (;$) { my($print_context) = @_; bless ["", $print_context], TestieErrorHandler; } sub message ($@) { my($teh) = shift @_; print STDERR $teh->[0], @_; $teh->[0] = ""; } sub showmessage ($@) { my($teh) = shift @_; print @_; } sub context ($@) { my($teh) = shift @_; if ($teh->[1]) { # print_context my($t) = join("", @_); print STDERR $teh->[0], $t; $teh->[0] = "\r" . (" " x length($t)) . "\r"; } } sub clear ($) { } sub complete ($$) { } ## testie error handler object package TestieChildErrorHandler; sub new ($) { my($fh) = @_; bless ["", "", $fh], TestieChildErrorHandler; } sub message ($@) { my($eh) = shift @_; $eh->clear if $eh->[0] ne "E" && $eh->[1] ne ""; $eh->[0] = "E"; $eh->[1] .= join("", @_); } sub showmessage ($@) { my($eh) = shift @_; $eh->clear if $eh->[0] ne "S" && $eh->[1] ne ""; $eh->[0] = "S"; $eh->[1] .= join("", @_); } sub context ($@) { my($eh) = shift @_; $eh->clear if $eh->[1] ne ""; my($fh, $t) = ($eh->[2], join("", @_)); ::tipc_write($fh, "C", $t); } sub clear ($) { my($eh) = shift @_; ::tipc_write($eh->[2], $eh->[0], $eh->[1], 1) if $eh->[1] ne ""; $eh->[0] = ""; $eh->[1] = ""; } sub complete ($$) { my($eh, $tctr) = @_; $eh->clear; my(@t, $k, $v); while (($k, $v) = each %$tctr) { my($t) = "\"" . quotemeta($k) . "\" => "; if (ref($v)) { $t .= "[" . join(", ", map { "\"".quotemeta($_)."\"" } @$v) . "]"; } else { $t .= $v; } push @t, $t; } ::tipc_write($eh->[2], "T", "{" . join(", ", @t) . "}"); } ## testie error counter object package TestieCounter; my @counters = ("errors", "require_errors", "test_attempts", "test_skips", "test_failures", "bad_files"); sub new () { my($tctr) = bless { "require_error_commands" => [] }, TestieCounter; foreach my $x (@counters) { $tctr->{$x} = 0; } $tctr; } sub add ($$) { my($tctr, $tctr1) = @_; foreach my $x (@counters) { $tctr->{$x} += $tctr1->{$x}; } push @{$tctr->{"require_error_commands"}}, @{$tctr1->{"require_error_commands"}}; $tctr; } ## main testie test object package Testie; ## read testie file my %_special_filerefs = ('stdin' => 1, 'stdout' => 2, 'stderr' => 2); %_variables = (); $timeout = 45; sub _get ($;$) { my($tt, $acrossfiles) = @_; my($lines) = $tt->{"_data"}; my $t; while (defined($t = shift @$lines)) { if (!ref $t) { ++$tt->{"_line"}; last; } elsif ($acrossfiles) { $tt->{"_file"} = $t->[0]; $tt->{"_line"} = $t->[1]; } else { unshift @$lines, $t; $t = undef; last; } } $t; } sub _unget ($$) { my($tt, $t) = @_; if (defined($t) && $t ne "") { unshift @{$tt->{"_data"}}, $t; --$tt->{"_line"}; } } # return a command at a given line number sub command_at ($$;$) { my($tt, $lineno, $script_type) = @_; return undef if !defined($lineno); $lineno =~ s/^\s*|\s*$//g; $script_type = 'script' if !defined($script_type); my($lineno_arr) = $tt->{$script_type . '_lineno'}; for ($i = 0; $i < @$lineno_arr; $i++) { return $tt->{$script_type}->[$i] if $lineno_arr->[$i] eq $lineno; } undef; } # report an error sub eh ($) { my($tt) = @_; $tt->{"_eh"}; } sub file_err ($$;$) { my($tt, $text, $lineno) = @_; $text .= "\n" if $text !~ /\n$/s; $lineno = $tt->{"_line"} if !defined($lineno); $tt->eh->message($tt->{"_file"}, ":", $lineno, ': ', $text); $tt->{'err'}++; } sub _shell_split (\@$\@$$) { my($arr, $fn, $lineno_arr, $text, $lineno) = @_; my($qf, $qb, $func, $out) = (0, 0, 0, ''); my($sq, $dq, $bq, $nl, $hh, $lb, $rb) = (-2, -2, -2, -2, -2, -2, -2); my($first, $pos) = (0, 0); $lineno -= ($text =~ tr/\n//); while ($pos < length $text) { $sq = ::index2($text, "\'", $pos) if $sq < $pos; $dq = ::index2($text, "\"", $pos) if $dq < $pos; $bq = ::index2($text, "\`", $pos) if $bq < $pos; $nl = ::index2($text, "\n", $pos) if $nl < $pos; $hh = ::index2($text, "#", $pos) if $hh < $pos; $lb = ::index2($text, "{", $pos) if $lb < $pos; $rb = ::index2($text, "}", $pos) if $rb < $pos; if ($qf == 1) { $qf = 0 if $sq < length $text; $out .= substr($text, $pos, $sq + 1 - $pos); $pos = $sq + 1; next; } elsif ($qf == 2) { $qf = 0 if $dq < length $text; $out .= substr($text, $pos, $dq - $pos) . '"'; $pos = $dq + 1; next; } # find minimum my($min) = ::min($sq, $dq, $bq, $nl, $hh, $lb, $rb); $out .= substr($text, $pos, $min - $pos) . substr($text, $min, 1); if ($sq == $min) { $qf = 1; $pos = $sq + 1; } elsif ($dq == $min) { $qf = 2; $pos = $dq + 1; } elsif ($bq == $min) { $qb = !$qb; $pos = $bq + 1; } elsif ($lb == $min) { $func++; $pos = $lb + 1; } elsif ($rb == $min) { $func--; $pos = $rb + 1; } elsif ($hh == $min) { $out .= substr($text, $min + 1, $nl - $min); $lineno++; $pos = $nl + 1; } elsif (!$qb && !$func && ($nl == $pos || substr($text, $nl - 1, 1) ne "\\")) { push @$arr, $out; push @$lineno_arr, "$fn:$lineno"; $out = ''; $lineno += (substr($text, $first, $nl - $first + 1) =~ tr/\n//); $first = $pos = $nl + 1; } else { $pos = $nl + 1; } } if ($first < length $text) { push @$arr, $out; push @$lineno_arr, "$fn:$lineno"; } if ($qf == 1) { "unmatched single quote"; } elsif ($qf == 2) { "unmatched double quote"; } elsif ($qb) { "unmatched backquote"; } else { ""; } } sub _read_text ($) { my($tt) = @_; my($r, $t) = (''); while (defined($t = $tt->_get())) { last if $t =~ /^\%/; $t =~ s/^\\\%/\%/; $r .= $t; } $tt->_unget($t); $r; } sub _read_text_into ($$) { my($tt, $section) = @_; $tt->{$section} = '' if !defined($tt->{$section}); $tt->{$section} .= $tt->_read_text(); } sub _read_script_section ($$$) { my($tt, $args, $script_type) = @_; my($lineno_type, $quiet_type) = ($script_type . '_lineno', $script_type . '_quietline'); $tt->{$lineno_type} = [] if !exists $tt->{$lineno_type}; $tt->{$quiet_type} = {} if !exists $tt->{$quiet_type}; my($quiet); if ($script_type eq 'require' & $args eq '-q') { $quiet = 1; } elsif ($args ne '') { $tt->file_err("arguments to '\%$script_type' ignored"); } #$tt->file_err("multiple '\%$script_type' sections defined") if $tt->{$script_type}; my($r) = $tt->_read_text(); my $count = @{$tt->{$lineno_type}}; my($what) = _shell_split(@{$tt->{$script_type}}, $tt->{"_file"}, @{$tt->{$lineno_type}}, $r, $tt->{"_line"} + 1); $tt->file_err("$what in '\%$script_type'") if $what ne ''; while ($quiet && $count < @{$tt->{$lineno_type}}) { my($line) = $tt->{$lineno_type}->[$count++]; $tt->{$quiet_type}->{$line} = 1; } } sub braces_to_regex ($$) { my($x, $mode) = @_; my($re, $message) = ("", undef); while ($x =~ /\A(.*?)\{\{(.*?)\}\}(.*)\z/) { my($before, $middle, $after) = ($1, $2, $3); if ($middle =~ /\A\?/) { $before =~ s/\s+\z//; $middle =~ s/\A\?\s*//; $middle =~ s/\s+\z//; $after =~ s/\A\s+//; $message = (defined($message) ? $message . " " . $middle : $middle); $x = $before . $after; } else { $before = quotemeta($before) if $mode == 1; $middle =~ s,(\A|[^\\]|\\\\)/,$1\\/,g; # not 100% right sadly $re .= $before . "(?:" . $middle . ")"; $x = $after; } } $x = quotemeta($x) if $mode == 1; wantarray ? ($re . $x, $message) : $re . $x; } sub _read_file_section ($$$$;$) { my($tt, $args, $secname, $prefix, $backup_file) = @_; $args =~ s/\s+$//; # split arguments to get fileref my(@args) = split(/\s+/, $args); # assert that we understand $secname die if $secname ne 'file' && $secname ne 'expect' && $secname ne 'expectv' && $secname ne 'expectx' && $secname ne 'ignore' && $secname ne 'ignorex' && $secname ne 'ignorev'; # check for alternates and length my($alternate, $delfirst, $whitespace, $base64, $regex_opts, $length) = (0, 0, 0, 0, '', undef); while (@args) { if ($args[0] =~ /\A-a/) { $alternate = 1; } elsif ($args[0] =~ /\A-d/) { $delfirst = 1; } elsif ($args[0] =~ /\A-i/) { $regex_opts .= "(?i)"; } elsif ($args[0] =~ /\A-e/) { $base64 = 1; } elsif ($args[0] =~ /\A-w/) { $whitespace = 1; } elsif ($args[0] =~ /\A\+(\d+)\z/) { $length = $1; } else { last; } $args[0] = "-$1" if $args[0] =~ /\A-.(.*)\z/; shift @args if $args[0] !~ /\A-./; } # make sure there are filerefs if (!@args && $backup_file) { push @args, $backup_file; } elsif (!@args) { push @args, "stdin" if $secname eq 'file'; push @args, "stdout" if $secname eq 'expect' || $secname eq 'expectv' || $secname eq 'expectx'; push @args, "all" if $secname eq 'ignore' || $secname eq 'ignorev' || $secname eq 'ignorex'; } # complain about '%file -aiw' if (($secname eq 'file' || $secname eq 'ignore' || $secname eq 'ignorev' || $secname eq 'ignorex') && $alternate) { $tt->file_err("'\%$secname -a' is illegal"); } if (($secname eq 'file' || $secname eq 'expectv') && $regex_opts) { $tt->file_err("'\%$secname -i' is illegal"); } if (($secname eq 'file' || $secname eq 'expectv') && $whitespace) { $tt->file_err("'\%$secname -w' is illegal"); } $secname .= "v" if $secname eq "expect" && $base64; if (($secname eq "filex" || $secname eq "expectx" || $secname eq "ignore" || $secname eq "ignorev" || $secname eq "ignorex") && $base64) { $tt->file_err("'\%$secname -e' is illegal"); } # read contents my($seclineno) = $tt->{"_line"}; my($firstline) = $tt->{"_file"} . ":" . ($seclineno + 1); my($file_data) = ""; if (defined($length)) { my($t); while (length($file_data) < $length && defined($t = $tt->_get())) { $file_data .= $t; if (length($file_data) > $length) { # save extra data from the first line $tt->_unget(substr($t, $length - length($file_data))); $file_data = substr($file_data, 0, $length); } } $tt->file_err("file too short", $seclineno) if length($file_data) != $length; } else { $file_data = $tt->_read_text(); } # modify contents based on flags $alternate = 1 if $secname eq 'ignore' || $secname eq 'ignorev' || $secname eq 'ignorex'; # 'ignore' always behaves like -a if ($delfirst) { $file_data =~ s{^.}{}mg; } if (($secname eq 'ignore' || $secname eq 'ignorev' || $secname eq 'ignorex') && $whitespace) { $file_data =~ tr/ \f\r\t\013//d; } if ($secname eq 'ignore') { $file_data =~ s{^(.+)}{braces_to_regex($1, 1)}meg; } elsif ($secname eq 'ignorev') { $file_data =~ s{^(.+)}{quotemeta($1)}meg; } elsif ($secname eq 'ignorex') { $file_data =~ s[\s*\{\{\?.*?\}\}\s*][]mg; } if ($regex_opts && $secname eq 'expect') { $file_data =~ s{\{\{}{\{\{$regex_opts}g; } elsif ($regex_opts) { $file_data =~ s{^(?=.)}{$regex_opts}mg; } if ($base64) { $file_data = MIME::Base64::decode_base64($file_data); } # stick contents where appropriate my($fn); foreach $fn (@args) { if (($fn eq 'stdin' && $secname ne 'file') || (($fn eq 'stdout' || $fn eq 'stderr') && $secname eq 'file') || ($fn eq 'all' && ($secname ne 'ignore' && $secname ne 'ignorev' && $secname ne 'ignorex'))) { $tt->file_err("'$fn' not meaningful for '\%$secname'", $seclineno); } my($hashkey) = $prefix . ":" . $fn; if (!($fn =~ m,\A[-A-Za-z_0-9.]+\z, || ($fn =~ m,\A[-A-Za-z_0-9./]+\z, && $fn !~ m,(\A\.\./|/\.\./|/\.\.\z|\A/|//|/\z),))) { $tt->file_err("bad filename '\%$secname $fn'", $seclineno); next; } elsif (!exists($tt->{$hashkey})) { push @{$tt->{$secname}}, $fn; $tt->{$hashkey} = []; } elsif (!$alternate) { $tt->file_err("'\%$secname $fn' already defined", $seclineno); } push @{$tt->{$hashkey}}, $file_data; my($num) = @{$tt->{$hashkey}} - 1; $tt->{"F:$fn"} = 1; $tt->{"firstline:$hashkey:$num"} = $firstline; $tt->{"whitespace:$hashkey:$num"} = 1 if $whitespace; } } sub _skip_section ($) { my($tt) = @_; my($t); while (defined($t = $tt->_get())) { last if $t =~ /^%/; } $tt->_unget($t); } sub parse ($) { my($tt) = @_; my($t, $read_command); # delete garbage my(@deletes, $k, $v); while (($k, $v) = each %$tt) { push @deletes, $k if $k ne "_data" && $k ne "err" && $k ne "_eh"; } foreach $k (@deletes) { delete $tt->{$k}; } while (defined($t = $tt->_get(1))) { if ($t =~ /^%\s*(\w+)\s*(.*?)\s*$/) { my($command) = lc($1); my($args) = $2; if ($command eq 'script' || $command eq 'test') { $tt->_read_script_section($args, 'script'); } elsif ($command eq 'require') { $tt->_read_script_section($args, 'require'); } elsif ($command eq 'info') { $tt->file_err("arguments to '\%info' ignored") if $args ne ''; $tt->_read_text_into('info'); } elsif ($command eq 'desc') { $tt->file_err("arguments to '\%desc' ignored") if $args ne ''; $tt->_read_text_into('info'); } elsif ($command eq 'cut') { $tt->_read_text_into('cut'); } elsif ($command eq 'stdin' || $command eq 'input') { $tt->_read_file_section($args, "file", "f", "stdin"); } elsif ($command eq 'file') { $tt->_read_file_section($args, 'file', 'f'); } elsif ($command eq 'stdout' || $command eq 'output') { $tt->_read_file_section($args, "expect", "e", "stdout"); } elsif ($command eq 'stderr') { $tt->_read_file_section($args, "expect", "e", "stderr"); } elsif ($command eq 'expect') { $tt->_read_file_section($args, 'expect', 'e'); } elsif ($command eq 'expectx') { $tt->_read_file_section($args, 'expectx', 'x'); } elsif ($command eq 'expectv' || $command eq 'expect_verbatim' || $command eq 'verbatim') { $tt->_read_file_section($args, 'expectv', 'v'); } elsif ($command eq 'ignore') { $tt->_read_file_section($args, 'ignore', 'i'); } elsif ($command eq 'ignorev') { $tt->_read_file_section($args, 'ignorev', 'i'); } elsif ($command eq 'ignorex') { $tt->_read_file_section($args, 'ignorex', 'i'); } elsif ($command eq 'include') { if ($args !~ /^\//) { my($oldfn) = $tt->{"_file"}; $oldfn =~ s/(\A|\/)[^\/]+\z/$1/; $args = $oldfn . $args; } if (open(INCLUDE, "<", $args)) { my(@ilines, $it); push @ilines, [$args, 0]; push @ilines, $it while defined($it = ); push @ilines, [$tt->{"_file"}, $tt->{"_line"}]; unshift @{$tt->{"_data"}}, @ilines; } else { $tt->file_err("\%include $args: $!"); } } elsif ($command eq 'eot') { unshift @{$tt->{"_data"}}, [$tt->{"_file"}, $tt->{"_line"}]; $tt->{"continue"} = 1; last; } elsif ($command eq 'eof') { 1 while defined($t = $tt->_get()); } else { $tt->file_err("unrecognized command '$command'"); $tt->_skip_section(); } $read_command = 1; } else { if ($t =~ /^%/) { $tt->file_err("bad '\%' command"); } elsif ($t !~ /^[\#!]/ && $t =~ /\S/) { $tt->file_err("warning: garbage ignored") if $read_command; $read_command = 0; } } } $tt; } sub read (*$;$) { my($fh, $teh, $fn) = @_; $fh = ::qualify_to_ref($fh, caller); my($t, $tt); $tt = bless { "err" => 0, "_data" => [[$fn, 0]], "_eh" => $teh }, Testie; push @{$tt->{"_data"}}, $t while defined($t = <$fh>); $tt->parse(); $tt; } sub have_file ($$) { my($tt, $fileref) = @_; exists($tt->{"F:$fileref"}); } sub empty ($) { my($tt) = @_; !exists($tt->{'script'}); } sub save_files ($&) { my($tt, $fileref_subr) = @_; my($fn, $dirn, $actual); # create implied subdirectories foreach $fn (keys %$tt) { next if $fn !~ m,\AF:(.*)/([^/]*)\z,; $dirn = $1; while (!-d $fileref_subr->($dirn)) { $fn = $dirn; $fn = $1 while ($fn =~ m,\A(.*)/([^/]*)\z, && !-d $fileref_subr->($1)); $actual = $fileref_subr->($fn); mkdir $actual || die "$actual: $!\n"; } } # write '%file' contents foreach $fn (@{$tt->{'file'}}) { $actual = $fileref_subr->($fn); next if !defined($actual); open OUT, ">", $actual || die "$actual: $!\n"; print OUT $tt->{"f:$fn"}->[0]; close OUT; } } sub script_text ($&$) { my($tt, $fileref_subr, $script_type) = @_; my($subbody, $var, $val) = ''; my($t) = ''; if (!$::expand_mode) { $t .= <<'EOD;'; testie_failed () { exitval=$? test $exitval = 0 || (echo; echo testie_failure:$exitval) >&2 exit $exitval } testie_subtest () { echo testie_subtest "$@" echo testie_subtest "$@" >&2 } trap testie_failed EXIT EOD; } my($scriptarr, $linenoarr) = ($tt->{$script_type}, $tt->{$script_type . "_lineno"}); my($last_unfinished) = 0; foreach my $i (0..$#{$tt->{$script_type}}) { my($ln, $text) = ($linenoarr->[$i], $scriptarr->[$i]); if (!$::expand_mode && !$last_unfinished) { $t .= "echo >&2; echo testie_lineno:$ln >&2\n"; } my(@c, @d); _shell_split(@c, "", @d, $text, 0); die if @c != 1; chomp $c[0]; next if $c[0] =~ /^\s*$/s; $last_unfinished = ($c[0] =~ /(?:\&\&|\|\||\|)\s*\z/); $c[0] =~ s,^(\s*)\./,$1../, if !$::expand_mode; $t .= $c[0] . "\n"; } $t; } sub output_error ($$$$) { my($tt, $fileref_subr, $script_type, $tctr) = @_; my($fp) = $tt->{'errprefix'}; if (!open(ERR, "<", $fileref_subr->('stderr'))) { $tt->eh->message($fp, $!, "\n"); ++$tctr->{"errors"}; return $tctr; } my($errortext, $subtest, $t, $lineno, $failure) = ('', ''); while ($t = ) { if ($t =~ /^testie_lineno:(.*)$/) { $lineno = $1; $errortext = ''; } elsif ($t =~ /^testie_failure:(.*)$/) { $failure = $1; } elsif ($t =~ /^testie_subtest (.*)$/) { $subtest = " subtest $1"; } else { $errortext .= $t; } } close ERR; $lineno = $fp if !defined($lineno); $lineno =~ s/: *\z//; my($failure_text); if (!defined($failure)) { $failure_text = "undefined error"; } elsif ($failure eq "timeout") { $failure_text = "timed out after $Testie::timeout sec"; } elsif ($failure == 1) { $failure_text = "failure"; } else { $failure_text = "error $failure"; } if (defined($script_type) && $script_type eq 'require') { $failure_text = "requirement $failure_text"; ++$tctr->{"require_errors"}; } else { ++$tctr->{"errors"}; } $errortext =~ s/\s*\z//; my($cmd) = $tt->command_at($lineno, $script_type); # exit early if quiet return $tctr if $tt->{$script_type . '_quietline'}->{$lineno} && $::verbose <= 0; $lineno .= $subtest; if ($errortext =~ /^testie_error:/) { while ($errortext =~ /^testie_error:([^\n]*)/g) { $tt->eh->message($lineno, ": ", $1, "\n"); } $errortext =~ s/^testie_error:([^\n]*)//g; $errortext =~ s/\s*//; $tt->eh->message($lineno, ": (There were other errors as well.)\n") if $errortext ne ''; } elsif (!defined($cmd)) { $tt->eh->message($lineno, ": $failure_text at undefined point in script\n"); } else { $cmd =~ s/^\s*|\s*$//g; $cmd =~ s/([\000-\037])/'^' . chr(ord($1) + ord('@'))/eg; $cmd =~ s/([\177-\377])/"\\" . sprintf("%03o", ord($1))/eg; if (length($cmd) > 40) { $cmd = substr($cmd, 0, 40) . "..."; } # if nonverbose requirement, remember command, don't print error if (defined($script_type) && $script_type eq 'require' && $::verbose <= 0) { push @{$tctr->{"require_error_commands"}}, $cmd; } else { $tt->eh->message($lineno, ": $failure_text at '$cmd'\n"); while ($errortext =~ /([^\n]*)/g) { $tt->eh->message($lineno, ": $1\n") if $1 ne ''; } } } $tctr; } sub _output_expectation_error ($$$$$) { my($fp, $efn, $etrack, $teh, $tctr) = @_; # fix subtest description if (defined($etrack->{"subtest"})) { $fp =~ s/: \z/ /; $fp .= "subtest " . $etrack->{"subtest"} . ": "; } if (defined($etrack->{"expectedline"})) { $fp = $etrack->{"expectedline"} . ": "; } # output message if ($efn eq 'stdout') { $teh->message($fp, "standard output has unexpected value starting at line " . $etrack->{"textline"} . "\n"); } elsif ($efn eq 'stderr') { $teh->message($fp, "standard error has unexpected value starting at line " . $etrack->{"textline"} . "\n"); } else { $teh->message($fp, "file $efn has unexpected value starting at line " . $etrack->{"textline"} . "\n"); } # output expected and text data if possible $etrack->{"expected"} = "" if $etrack->{"expected"} eq "\376"; $etrack->{"expected"} =~ s/\r?\n?\z//; $etrack->{"text"} = "" if $etrack->{"text"} eq "\376"; $etrack->{"text"} =~ s/\r?\n?\z//; if ($etrack->{"expected"} =~ /\A[\t\040-\176]*\z/ && $etrack->{"text"} =~ /\A[\t\040-\176]*\z/) { $etrack->{"expected"} =~ s/\s*\{\{\?.*?\}\}\s*//g if $etrack->{"mode"} != 0; $teh->message($fp, $efn, ":", $etrack->{"textline"}, ": expected '", $etrack->{"expected"}, "'\n", $fp, $efn, ":", $etrack->{"textline"}, ": but got '", $etrack->{"text"}, "'\n"); } if (defined($etrack->{"message"})) { $teh->message($fp, $efn, ":", $etrack->{"textline"}, ": ", $etrack->{"message"}, "\n"); } # maintain error count ++$tctr->{"errors"}; return $tctr; } sub _expect_trim_whitespace ($) { my($out) = ""; foreach my $x (split(/(\{\{.*?\}\})/, $_[0])) { $x =~ tr/ \f\r\t\013//d if $x !~ /\A\{\{/; $out .= $x; } return $out; } sub _check_one_typed_expect ($$$$$) { my($tt, $raw_text, $fn, $ignores, $etrack) = @_; my($mode) = ($fn =~ /^v/ ? 0 : ($fn =~ /^e/ ? 1 : 2)); my($expnum) = 0; foreach my $exp (@{$tt->{$fn}}) { my($text) = $raw_text; my($whitespace) = $tt->{"whitespace:$fn:$expnum"}; # escape in common case return 0 if $text eq $exp; # check that files really disagree (in later modes) if ($mode > 0) { # ignore differences in amounts of vertical whitespace $text =~ s/[ \f\r\t\013]+\n/\n/g; $text =~ s/\n\n+\z/\n/; $text =~ s/\A\n//; $exp =~ s/[ \f\r\t\013]+\n/\n/g; $exp =~ s/\n\n+\z/\n/; return 0 if $text eq $exp; # ignore explicitly ignored text $text = $ignores->($text) if $ignores; } # line-by-line comparison my(@tl) = (split(/\n/, $text), "\376"); my(@el) = (split(/\n/, $exp), "\376"); my($tp, $ep, $subtest, $message) = (0, 0, undef, undef); while ($tp < @tl && $ep < @el) { # skip blank lines and ignored lines ++$ep while $el[$ep] eq '' && $mode > 0; ++$tp while ($tl[$tp] eq '' && $mode > 0) || $tl[$tp] eq "\377"; # process testie_subtest if (length($tl[$tp]) > 15 && substr($tl[$tp], 0, 15) eq "testie_subtest ") { $subtest = substr($tl[$tp], 15); $tp++; next; } # compare lines my($tline, $eline) = ($tl[$tp], $el[$ep]); if ($whitespace) { $tline =~ tr/ \f\r\t\013//d; $eline = _expect_trim_whitespace($eline); } if ($mode != 0 && $eline =~ /\{\{/) { my($re); ($re, $message) = braces_to_regex($eline, $mode); last if $tline !~ m/\A$re\z/; } elsif ($mode == 2) { last if $tline !~ m/\A$eline\z/; } elsif ($tline ne $eline) { last; } $tp++, $ep++; } return 0 if $tp >= @tl || $ep >= @el; if (!defined($etrack->{"textline"}) || $tp + 1 > $etrack->{"textline"}) { $etrack->{"text"} = $tl[$tp]; $etrack->{"expected"} = $el[$ep]; $etrack->{"textline"} = $tp + 1; if (defined($tt->{"firstline:$fn:$expnum"}) && $tt->{"firstline:$fn:$expnum"} =~ /^(.*):(\d+)$/) { $etrack->{"expectedline"} = $1 . ":" . ($2 + $ep); } else { $etrack->{"expectedline"} = undef; } $etrack->{"mode"} = $mode; $etrack->{"subtest"} = $subtest; $etrack->{"message"} = $message; } ++$expnum; } return -1; } sub _create_ignores ($$) { my($tt, $efn) = @_; my($ignores, $wignores, $body) = ("", ""); foreach my $fn ($efn, "all") { next if !exists($tt->{"i:$fn"}); for (my $expnum = 0; $expnum < @{$tt->{"i:$fn"}}; ++$expnum) { if ($tt->{"whitespace:i:$fn:$expnum"}) { $wignores .= $tt->{"i:$fn"}->[$expnum] . "\n"; } else { $ignores .= $tt->{"i:$fn"}->[$expnum] . "\n"; } } } # ignore testie messages $ignores .= "testie_lineno:.*\ntestie_error:.*\n" if $efn eq "stderr"; if ($ignores eq "" && $wignores eq "") { return undef; } elsif ($wignores eq "") { $ignores =~ s{^([ \t]*\S[^\n]*)}{\$t =~ s/^$1\[ \\t\]*\$/\\377/mg;}mg; $body = "sub (\$) { my(\$t) = \@_; $ignores \$t; }\n"; } else { $ignores =~ s{^([ \t]*\S[^\n]*)}{s/\\A$1\[ \\t\]*\\z/\\377/;}mg; $wignores =~ s{^(\S[^\n]*)}{\$_ = "\\377" if \$x =~ m/\\A$1\\z/;}mg; $body = "sub (\$) { my(\$t) = \@_; my(\$x); join(\"\\n\", map { " . "\$x = \$_; \$x =~ tr/ \\f\\r\\t\\013//d;\n$ignores$wignores " . "\"\$_\\n\" } split /\\n/, \"\$t\\n\"); }\n"; } return eval($body); } sub _check_one_expect ($$$$) { my($tt, $fileref_subr, $efn, $tctr) = @_; my($fp) = $tt->{'errprefix'}; my($etrack) = {}; # read file text if (!open(IN, "<", $fileref_subr->($efn))) { $tt->eh->message($fp, $efn, ": ", $!, "\n"); ++$tctr->{"errors"}; return 0; } my($raw_text) = ; $raw_text = '' if !defined($raw_text); close IN; # prepare $ignores my($ignores) = _create_ignores($tt, $efn); # now compare alternates foreach my $fn ("v:$efn", "e:$efn", "x:$efn") { return 0 if _check_one_typed_expect($tt, $raw_text, $fn, $ignores, $etrack) >= 0; } # if we get here, none of the attempts matched _output_expectation_error($fp, $efn, $etrack, $tt->eh, $tctr); } sub check_expects ($$$) { my($tt, $fileref_subr, $tctr) = @_; my($fp) = $tt->{'errprefix'}; local($/) = undef; my($expectx) = 0; my($tp, @tl, $ep, @el); # check expected files my(%done); foreach my $efn (@{$tt->{'expect'}}, @{$tt->{'expectx'}}, @{$tt->{'expectv'}}) { next if $done{$efn}; _check_one_expect($tt, $fileref_subr, $efn, $tctr); $done{$efn} = 1; } 0; } package main; my($dir, @show, $show_stdout, $show_stderr, %child_pids); my($SHELL) = "/bin/sh"; sub script_fn_to_fn ($) { my($fn) = @_; $fn; } sub out_script_fn_to_fn ($) { my($fn) = @_; "$dir/$fn"; } sub _shell ($$$$$) { my($dir, $scriptfn, $stdin, $stdout, $stderr) = @_; $scriptfn = "./$scriptfn" if $scriptfn !~ m|^/|; # Create a new process group so we can (likely) kill any children # processes the script carelessly left behind. Thanks, Chuck Blake! my($child_pid) = fork(); if (!defined($child_pid)) { die "cannot fork: $!\n"; } elsif ($child_pid == 0) { eval { setpgrp() }; chdir($dir); open(STDIN, "<", $stdin) || die "$stdin: $!\n"; open(STDOUT, ">", $stdout) || die "$stdout: $!\n"; open(STDERR, ">", $stderr) || die "$stderr: $!\n"; my($var, $val); $ENV{$var} = $val while (($var, $val) = each %Testie::_variables); $ENV{"rundir"} = ".."; exec $SHELL, "-e", $scriptfn; } else { $running_pid = $child_pid; my($result) = undef; if ($Testie::timeout > 0) { my($before) = Time::HiRes::time(); my($delta) = 10; do { Time::HiRes::usleep($delta); $result = $? if waitpid($child_pid, WNOHANG) > 0; $delta = ($delta < 150000 ? $delta * 2 : 300000); } while (!defined($result) && Time::HiRes::time() < $before + $Testie::timeout); if (!defined($result)) { if (open(X, ">>", out_script_fn_to_fn($stderr))) { print X "testie_failure:timeout\n"; close X; } $result = 124; } $result = 124 if !defined($result); } else { waitpid($child_pid, 0); } $result = $? if !defined($result); # sleep for 1 millisecond to give remaining background jobs a chance # to die select(undef, undef, undef, 0.001); kill('HUP', -$child_pid); # kill any processes left behind $running_pid = 0; $result; } } sub execute_test ($$) { my($tt, $fn) = @_; my($tctr, $teh) = (TestieCounter::new, $tt->eh); ++$tctr->{"test_attempts"}; my($f); # count attempt $tt->{"errprefix"} = $fn . ": "; # print description in superverbose mode if ($::verbose > 1) { return $tctr if $tt->empty; if ($tt->{'info'}) { my($desc) = $tt->{'info'}; $desc =~ s/^(.*?)\t/$1 . (' ' x (8 - (length($1) % 8)))/egm while $desc =~ /\t/; $desc =~ s/\r\n/\n/g; $desc =~ tr/\r/\n/; $desc =~ s/\A\n+//s; $desc =~ s/\n\n.*\z//s; $desc =~ s/^/ /mg; $desc .= "\n" if $desc !~ /\n\z/; $teh->message($fn, " Information:\n", $desc); } $teh->message($fn, " Results:\n"); $tt->{'errprefix'} = " "; } # maybe note that we're running the test if ($::verbose == 1) { $teh->message($tt->{'errprefix'}, "Running...\n"); } elsif ($::verbose == 0) { my($cr_out) = "[" . $tt->{"errprefix"}; $cr_out =~ s/:\s+\z//; $cr_out = "[..." . substr($cr_out, -73) if length($cr_out) > 76; $teh->context($cr_out, "] "); } # check requirements if (exists $tt->{'require'}) { open(SCR, ">", "$dir/\%require") || die "$dir/\%require: $!\n"; print SCR $tt->script_text(\&script_fn_to_fn, 'require'); close SCR; if (!$expand_mode) { my($exitval) = _shell($dir, '%require', '/dev/null', '/dev/null', script_fn_to_fn('stderr')); # if it exited with a bad value, quit if ($exitval) { return $tt->output_error(\&out_script_fn_to_fn, 'require', $tctr); } } } # save the files it names $tt->save_files(\&out_script_fn_to_fn); # save the script open(SCR, ">", "$dir/\%script") || die "$dir/\%script: $!\n"; print SCR $tt->script_text(\&script_fn_to_fn, 'script'); close SCR; # exit if expand mode return $tctr if $expand_mode; # run the script my($actual_stdin) = ($tt->have_file('stdin') ? script_fn_to_fn('stdin') : "/dev/null"); my($actual_stdout) = ($show_stdout || $tt->have_file('stdout') ? script_fn_to_fn('stdout') : "/dev/null"); my($actual_stderr) = script_fn_to_fn('stderr'); my($exitval) = _shell($dir, '%script', $actual_stdin, $actual_stdout, $actual_stderr); # expand "--show-alls" my(@xshow); foreach $f (@show) { if ($f->[0] eq "*") { my(%expanded, @shownit, $k, $v); %expanded = ("stdout" => 1, "stderr" => 1); push @xshow, ["stdout", $f->[1]], ["stderr", $f->[1]]; while (($k, $v) = each %$tt) { next if $k !~ /\A[exv]:(.*)\z/ || exists $expanded{$1}; $expanded{$1} = 1; push @shownit, [$1, $f->[1]]; } push @xshow, sort { $a->[0] cmp $b->[0] } @shownit; } else { push @xshow, $f; } } # echo files foreach $f (@xshow) { $efn = $f->[0]; if (-r out_script_fn_to_fn($efn)) { $teh->showmessage("$fn: ", $efn, "\n", "=" x 79, "\n"); local($/) = undef; open(X, "<", out_script_fn_to_fn($efn)); my($text) = ; close(X); if ($f->[1] && defined($text)) { my($ignores) = Testie::_create_ignores($tt, $efn); if ($ignores) { $text = $ignores->($text); $text =~ s/^\377\n//mg; } } $teh->showmessage($text) if defined $text; $teh->showmessage("=" x 79, "\n"); } elsif ($efn ne "*") { $teh->showmessage("$fn: $efn does not exist\n"); } } if ($exitval) { # if it exited with a bad value, quit $tt->output_error(\&out_script_fn_to_fn, 'script', $tctr); } elsif ($tt->check_expects(\&out_script_fn_to_fn, $tctr)) { # expectsnothing to do } else { # success, print message if verbose if ($::verbose > 0 && !$tt->empty && $tctr->{"errors"} == 0) { $teh->message($tt->{'errprefix'}, "Success!\n"); } } $teh->message("\n") if $::verbose > 1; return $tctr; } sub run_test_read_file ($$) { my($fn, $teh) = @_; # read the testie my($tt, $display_fn, $close_in); if (!defined($fn) || $fn eq '-') { if (!open(IN, "<&=STDIN")) { $teh->message(": $!\n"); return (); } $display_fn = ""; } elsif (-d $fn) { $teh->message($fn, ": is a directory\n"); return (); } else { if (!open(IN, "<", $fn)) { $teh->message($fn, ": $!\n"); return (); } $display_fn = $fn; $close_in = 1; } $tt = Testie::read(IN, $teh, $display_fn); return ($tt, $display_fn, $close_in); } sub run_test_body ($$) { my($fn, $teh) = @_; my($tctr) = TestieCounter::new; my($tt, $display_fn, $close_in) = run_test_read_file($fn, $teh); if (!defined($tt)) { ++$tctr->{"bad_files"}; return $tctr; } my($suffix) = ''; while (1) { my($tctr1) = execute_test($tt, $display_fn . $suffix); if ($tctr1->{"require_errors"}) { ++$tctr->{"test_skips"}; } elsif ($tctr1->{"errors"}) { ++$tctr->{"test_failures"}; } $tctr->add($tctr1); last if !exists $tt->{'continue'}; if (!($suffix =~ s/^<(\d+)>$/"<" . ($1+1) . ">"/e)) { $suffix = "<2>"; } $tt->parse(); } close IN if $close_in; return $tctr; } sub run_test ($$$) { my($fn, $teh, $testnumber) = @_; if (!$::expand_mode) { $dir = "testie$$" . ($testnumber ? "-$testnumber" : ""); if (-d $dir) { $teh->message("warning: $dir directory exists; removing it\n"); system("/bin/rm -rf $dir"); -d $dir && die "cannot remove $dir directory: $!\n"; } mkdir $dir || die "cannot create $dir directory: $!\n"; } my($tctr) = run_test_body($fn, $teh); $teh->complete($tctr); system("/bin/rm -rf $dir") if !$preserve_temporaries; undef $dir; return $tctr; } sub cleanup () { kill("HUP", -$running_pid) if $running_pid; # kill any processes left behind my(@children) = keys %child_pids; foreach my $kid (@children) { kill("HUP", $kid) if $child_pids{$kid}; } system("/bin/rm -rf $dir 2>/dev/null") if defined($dir) && !$preserve_temporaries; } $SIG{'INT'} = $SIG{'QUIT'} = $SIG{'TERM'} = sub { cleanup; exit(1); }; $SIG{'__DIE__'} = \&cleanup; # child processing sub testie_child () { my($p2cr, $p2cw, $c2pr, $c2pw); pipe($p2cr, $p2cw); pipe($c2pr, $c2pw); $p2cw->autoflush(1); defined($c2pr->blocking(0)) || die "cannot set nonblocking: $!"; defined($p2cr->blocking(0)) || die "cannot set nonblocking: $!"; binmode $p2cr; binmode $p2cw; binmode $c2pr; binmode $c2pw; my($child_pid) = fork(); if (!defined($child_pid)) { die "cannot fork: $!\n"; } elsif ($child_pid) { $p2cr->close; $c2pw->close; $child_pids{$child_pid} = 1; return [$c2pr, $p2cw, "", $child_pid]; } $SIG{"CHLD"} = "DEFAULT"; # reset SIG{CHLD} handler from parent eval { setpgrp() }; $::quiet_ebadf = 1; $p2cw->close; $c2pr->close; my($p2crbuf, $testnumber) = ("", 0); my($teh) = TestieChildErrorHandler::new($c2pw); my($command, $arg, $rin, $rout, $win, $wout); $rin = $win = ""; vec($rin, $p2cr->fileno, 1) = 1; vec($win, $c2pw->fileno, 1) = 1; while (1) { if ((($command, $arg) = tipc_read($p2cr, \$p2crbuf))) { if ($command eq "T") { run_test($arg, $teh, $testnumber); ++$testnumber; } elsif ($command eq "X") { exit(0); } else { print STDERR "ipc error: bad command $command\n"; exit(1); } } tipc_error if select($rout = $rin, undef, $wout = $win, undef) < 0; # EPIPE/SIGPIPE to catch dead parent tipc_error if !defined(syswrite($c2pw, " ")); } } sub testie_child_reaper { my $kid; while (($kid = waitpid(-1, WNOHANG)) > 0) { delete $child_pids{$kid}; } } sub testie_parent_loop (\@$$$) { my($tests, $tctr, $teh, $jobs) = @_; my($testpos, $testdone, $rin, $rout) = (0, 0, "", ""); my(@children, @child_out, $command, $arg); $SIG{"CHLD"} = \&testie_child_reaper; for (my $i = 0; $i < $jobs; ++$i) { last if $testpos == @$tests; push @children, testie_child; my($c2pr, $p2cw) = ($children[$i]->[0], $children[$i]->[1]); tipc_write($p2cw, "T", $tests->[$testpos]); ++$testpos; vec($rin, $c2pr->fileno, 1) = 1; push @child_out, []; } while ($testdone < @$tests) { tipc_error if select($rout = $rin, undef, undef, undef) < 0; for (my $i = 0; $i < @children; ++$i) { my($c2pr, $p2cw, $c2prbufref) = ($children[$i]->[0], $children[$i]->[1], \($children[$i]->[2])); while ((($command, $arg) = tipc_read($c2pr, $c2prbufref))) { if ($command eq "C") { $teh->context($arg); } elsif ($command eq "S" || $command eq "E") { push @{$child_out[$i]}, [$command, $arg]; } elsif ($command eq "T") { my($tctr1) = eval($arg); bless $tctr1, TestieCounter; $tctr->add($tctr1); foreach my $x (@{$child_out[$i]}) { if ($x->[0] eq "S") { $teh->showmessage($x->[1]); } else { $teh->message($x->[1]); } } $child_out[$i] = []; ++$testdone; if ($testpos < @$tests) { tipc_write($p2cw, "T", $tests->[$testpos]); ++$testpos; } } else { die "ipc error: bad command $command"; } } } } } # help/usage sub help () { print <<'EOD;'; 'Testie' is a simple test harness. Usage: testie [OPTIONS] [FILE]... Options: VARIABLE=VALUE Variable settings for test script. -V, --verbose Print information for successful tests. -VV, --superverbose Print initial %info for all tests. -s, --show TESTIEFILE Show contents of TESTIEFILE on completion. -S, --show-raw TESTIEFILE Like --show, but include ignored lines. --show-all Show contents of all TESTIEFILEs on completion. --show-all-raw Like --show-all, but include ignored lines. --preserve-temporaries Preserve temporary files. -e, --expand Expand test files into current directory. -p, --path DIR Prepend DIR to PATH. -t, --timeout T Set timeout to T [45 sec]. -v, --version Print version information and exit. --help Print this message and exit. Report bugs and suggestions to . EOD; exit(0); } sub usage () { print STDERR <<'EOD;'; Usage: testie [-V] [FILE]... Try 'testie --help' for more information. EOD; exit(1); } sub print_version () { print <<'EOD;'; Testie 1.3 Copyright (c) 2002-2016 Eddie Kohler Copyright (c) 2002-2003 International Computer Science Institute Copyright (c) 2004-2007 Regents of the University of California Copyright (c) 2008-2010 Meraki, Inc. This is free software; see the source for copying conditions. There is NO warranty, not even for merchantability or fitness for a particular purpose. EOD; exit(0); } sub argcmp ($$$;\$) { my($arg, $opt, $min_match, $store) = @_; $$store = undef if defined($store); return 0 if substr($arg, 0, 2 + $min_match) ne substr($opt, 0, 2 + $min_match); my($eq) = index($arg, '='); my($last) = ($eq >= 0 ? $eq : length($arg)); return 0 if $last > length($opt) || substr($arg, 0, $last) ne substr($opt, 0, $last); return 0 if !defined($store) && $eq >= 0; $$store = substr($arg, $eq + 1) if defined($store) && $eq >= 0; 1; } # directory searching sub search_dir ($$) { my($dir, $aref) = @_; $dir =~ s/\/+$//; if (!opendir(DIR, $dir)) { print STDERR "$dir: $!\n"; return; } my(@f) = grep { !/^\.\.?$/ } readdir(DIR); closedir(DIR); foreach my $f (sort { $a cmp $b } @f) { if (-d "$dir/$f") { &search_dir("$dir/$f", $aref); } elsif ($f =~ /^[^#\.].*\.testie$/) { push @$aref, "$dir/$f"; } } } # argument processing $dir = undef; my(@tests, $arg, $jobs, @pathprepend); $Testie::_variables{"LC_ALL"} = "C"; while (@ARGV) { $_ = shift @ARGV; if (/^([A-Za-z_]\w*)=(.*)$/s) { $Testie::_variables{$1} = $2; } elsif (/^-$/) { push @tests, $_; } elsif (!/^-/) { if (-d $_) { search_dir($_, \@tests); } else { push @tests, $_; } } elsif (/^-v$/ || argcmp($_, '--version', 4)) { print_version; } elsif (/^-q$/ || argcmp($_, '--quiet', 1)) { $::verbose = -1; } elsif (/^-V$/ || argcmp($_, '--verbose', 4)) { $::verbose = 1; } elsif (/^-VV$/ || argcmp($_, '--superverbose', 2)) { $::verbose = 2; } elsif (/^-e$/ || argcmp($_, '--expand', 1)) { $expand_mode = 1; $preserve_temporaries = 1; $dir = "."; } elsif (argcmp($_, '--help', 1)) { help; } elsif (argcmp($_, '--preserve-temporaries', 2) || argcmp($_, '--preserve-temps', 2)) { $preserve_temporaries = 1; } elsif (/^-p$/ || argcmp($_, '--path', 2)) { usage if @ARGV == 0; push @pathprepend, shift @ARGV; } elsif (/^-p(.+)$/) { push @pathprepend, $1; } elsif (argcmp($_, '--path', 2, $arg)) { push @pathprepend, $arg; } elsif (/^-s$/ || argcmp($_, '--show', 2)) { usage if @ARGV == 0; push @show, [(shift @ARGV), 1]; } elsif (/^-s(.+)$/) { push @show, [$1, 1]; } elsif (argcmp($_, '--show', 2, $arg)) { push @show, [$arg, 1]; } elsif (/^-S$/ || argcmp($_, '--show-raw', 6)) { usage if @ARGV == 0; push @show, [(shift @ARGV), 0]; } elsif (/^-S(.+)$/) { push @show, [$1, 0]; } elsif (argcmp($_, '--show-raw', 6, $arg)) { push @show, [$arg, 0]; } elsif (argcmp($_, '--show-all', 6)) { push @show, ["*", 1]; } elsif (argcmp($_, '--show-all-raw', 9)) { push @show, ["*", 0]; } elsif (/^-t$/ || argcmp($_, '--timeout', 1)) { usage if @ARGV == 0; $Testie::timeout = shift @ARGV; } elsif (/^-t(.+)$/) { $Testie::timeout = $1; } elsif (argcmp($_, '--timeout', 1, $arg)) { $Testie::timeout = $arg; } elsif (/^-j$/ || argcmp($_, "--jobs", 1)) { usage if @ARGV == 0 || $ARGV[0] !~ /\A\d+\z/; $jobs = shift @ARGV; } elsif (/^-j(\d+)$/) { $jobs = $1; } elsif (argcmp($_, "--jobs", 1, $arg) && $arg =~ /\A\d+\z/) { $jobs = $arg; } else { usage; } } # prepend to path if (@pathprepend) { my($i, $cwd); chomp($cwd = `pwd`); for ($i = 0; $i != @pathprepend; ++$i) { if ($pathprepend[$i] !~ m{\A/}) { $pathprepend[$i] =~ s{\A\./}{}; $pathprepend[$i] = $cwd . "/" . $pathprepend[$i]; } } $ENV{"PATH"} = join(":", @pathprepend) . ":" . $ENV{"PATH"}; } # check @show for stdout/stderr foreach my $s (@show) { $show_stdout = 1 if $s->[0] eq 'stdout' || $s->[0] eq "*"; $show_stderr = 1 if $s->[0] eq 'stderr' || $s->[0] eq "*"; } push @tests, '-' if !@tests; my($tctr) = TestieCounter::new; my($teh) = TestieErrorHandler::new(@tests > 1 && -t STDERR); if ($jobs && $jobs > 1) { testie_parent_loop(@tests, $tctr, $teh, $jobs); } else { my($testnumber) = 0; foreach my $test (@tests) { my($tctr1) = run_test($test, $teh, $testnumber); $tctr->add($tctr1); ++$testnumber; } } # Print messages about failed requirements @require_error_commands = sort { $a cmp $b } @{$tctr->{"require_error_commands"}}; if (@require_error_commands) { # make list unique for (my $i = 1; $i < @require_error_commands; ) { if ($require_error_commands[$i] eq $require_error_commands[$i - 1]) { splice(@require_error_commands, $i, 1); } else { ++$i; } } $teh->message("testie: requirement failures blocked ", $tctr->{"require_errors"}, ($tctr->{"require_errors"} > 1 ? " tests" : " test"), ", use '-V' for details\n"); $teh->message("testie: (", (@require_error_commands > 1 ? "commands" : "command"), " '", join("', '", @require_error_commands), "')\n"); } my($attempts, $failures, $skips, $successes) = ($tctr->{"test_attempts"}, $tctr->{"test_failures"}, $tctr->{"test_skips"}, $tctr->{"test_attempts"} - $tctr->{"test_failures"} - $tctr->{"test_skips"}); $teh->message("testie: ", $successes, ($successes == 1 ? " success, " : " successes, "), $failures, ($failures == 1 ? " failure, " : " failures, "), $skips, " skipped\n"); if ($tctr->{"bad_files"} > 0) { exit(2); } elsif ($attempts == 0 || ($tctr->{"errors"} == 0 && $skips < $attempts)) { exit(0); } else { exit(1); } =pod =head1 NAME testie - simple test harness =head1 SYNOPSIS testie [OPTIONS] [FILE]... =head1 DESCRIPTION Testie is a simple test harness. A testie test comprises a shell script and, optionally, input and expected output files for that script. Testie runs the script; the test succeeds if all of the script commands succeed, and the actual output files match expectations. Testie accepts test filenames and directories as arguments. Directories are recursively searched for F<*.testie> files. It reports problems for failed tests, plus a summary. Testie exits with status 0 if all tests succeed, 1 if any test fails, and 2 if a test fails due to an internal error. Tests whose B<%require> prerequisites fail do not affect the exit status, except that if all tests' prerequisites fail, the return status is 1 instead of 0. =head1 OPTIONS =over 8 =item B<-j>I, B<--jobs>=I Run up to I tests simultaneously. Like Make's B<-j> option. =item I=I Provide an environment variable setting for I within the script. =item B<-s>, B<--show> I Echo the contents of I on completion. I should be one of the filenames specified by B<%file> or B<%expect>, or B or B. Leaves out any ignored lines. =item B<-S>, B<--show-raw> I Like B<--show>, but includes any ignored lines. =item B<--show-all> Calls B<--show> for all filenames specified by any B<%expect>, plus B and B. Leaves out any ignored lines. =item B<--show-all-raw> Like B<--show-all>, but includes any ignored lines. =item B<-e>, B<--expand> Don't run the given test; instead, expand its files into the current directory. The script is stored in a file called F<%script>. =item B<--preserve-temporaries> Preserve temporary test directories. Testie runs each test in its own subdirectory of the current directory. Test directories are named F, and are typically removed on test completion. Examining the contents of a test directory can be useful when debugging a test. =item B<-p>, B<--path> I Prepend I to the C environment variable before running the test script. =item B<-V>, B<--verbose> Print information to standard error about successful tests as well as unsuccessful tests. =item B<-VV>, B<--superverbose> Like B<--verbose>, but use a slightly different format, and additionally print every test's B<%info> section before the test results. =item B<-q>, B<--quiet> Don't print information to the terminal while running multiple tests. =item B<-v>, B<--version> Print version number information and exit. =item B<--help> Print help information and exit. =back =head1 FILE FORMAT Testie test files consist of several sections, each introduced by a line starting with B<%>. There must be, at least, a B<%script> section. The B<%file> and B<%expect> sections define input and output files by name. =over 8 =item B<%script> The B shell script that controls the test. Testie will run each command in sequence. Every command in the script must succeed, with exit status 0, or the test will fail. Use B<%file> sections to define script input files and B<%expect> sections to check script output files for expected values. The B<%script> section can contain subtests. To start a new subtest, execute a command like S>. Testie will report the problematic C when standard output or error doesn't match an expected value. The script's environment is populated with any Is set on the testie command line with B=I> syntax. Also, the B<$rundir> environment variable is set to the directory in which testie was originally run. =item B<%require [-q]> An B shell script defining prerequisites that must be satisfied before the test can run. Every command in the script must succeed, with exit status 0, for the test to run. Standard output and error are not checked, however. The B<-q> flag tells testie not to print an error message if a requirement fails. Testie runs the requirement script before creating any other test files. For example, contents of B<%file> sections are not available. =item B<%info> A short description of the test. In B<--superverbose> mode, the first paragraph of its contents is printed before the test results. =item B<%cut> This section is ignored. It is intended to comment out obsolete parts of the test. =item B<%file [-de] [+I] I...> Create an input file for the script. I can be B, which sets the script's standard input. If B<+>I is provided, the file data consists of the I bytes following this line; otherwise, it consists of the data up to the next section. The B<-d> flag tells testie to delete the first character of each line in the section. The B<-e> flag indicates that the section was MIME Base64-encoded (see L); it is decoded before use. To include a file with lines that start with B<%> (which would normally start a new section), use B<-d> and preface each line of the file with a space, or use B<-e>. =item B<%expect [-adeiw] [+I] I...> Define an expected output file. Differences between the script's output I and the contents of the B<%expect> section will cause the test to fail. I can be B, for standard output. If B<+>I is provided, the file data consists of the I bytes following this line; otherwise, it consists of the data up to the next section. After running the script, testie compares the I generated by the script with the provided data. The files are compared line-by-line. Testie ignores blank lines, differences in trailing whitespace, and lines in the script output that match B<%ignore> patterns (see below). The B<-w> flag causes testie to ignore differences in amount of whitespace within each line. B<%expect> lines can contain Perl regular expressions, enclosed by two sets of braces. The B<%expect> line foo{{(bar)?}} matches either C or C. The B<-i> flag makes all such regular expressions case-insensitive. (Text outside of regular expressions must match case.) Document an B<%expect> line with C<{{?comment}}> blocks. For example: foo {{? the sort was in the right order}} Testie ignores whitespace before and after the C<{{?comment}}> block, and if the actual output differs from this expected line, it prints the comment in addition to the line differences. The B<-a> flag marks this expected output as an alternate. Testie will compare the script's output file with each provided alternate; the test succeeds if any of the alternates match. The B<-d> flag behaves as in B<%file>. =item B<%expectv [-ade] [+I] I...> Define a literal expected output file. This behaves like B<%expect>, except that the script's output file must match the provided data I: B<%expectv> never ignores whitespace differences, does not treat C<{{}}> blocks as regular expressions, and does not parse B<%ignore> patterns. =item B<%expectx [-adiw] [+I] I...> Define a regular-expression expected output file. This behaves like B<%expect>, except that every line is treated as a regular expression. C<{{?comment}}> blocks are ignored, but other brace pairs are treated according to the normal regular expression rules. =item B<%stdin [-de] [+I]> Same as B<%file stdin>. =item B<%stdout [-adeiw] [+I]> Same as B<%expect stdout>. =item B<%stderr [-adeiw] [+I]> Same as B<%expect stderr>. =item B<%ignorex [-di] [+I] [I]> Each line in the B<%ignorex> section is a Perl regular expression. Lines in the supplied I that match any of those regular expressions will not be considered when comparing files with B<%expect> data. The regular expression must match the whole line. I may be B, in which case the regular expressions will apply to all B<%expect> files. C<{{?comment}}> blocks are ignored. =item B<%ignore>, B<%ignorev [-adeiw] [+I] [I]> Like B<%ignorex>, but B<%ignore> parses regular expressions only inside double braces (C<{{ }}>), and B<%ignorev> lines must match exactly. =item B<%include I> Interpolate the contents of another testie file. =item B<%eot> Marks the end of the current test. The rest of the file will be parsed for additional tests. =item B<%eof> The rest of the file is ignored. =back =head1 EXAMPLE This simple testie script checks that 'grep -c' works for a simple output file. %script grep -c B. %stdin Bfoo B %stdout 1 =head1 ENVIRONMENT By default, testie sets the C environment variable to "C"; without this setting commands like B have unpredictable effects. To set C to another value, set it in the B<%script> section. =head1 AUTHOR Eddie Kohler,