563 lines
14 KiB
Perl
563 lines
14 KiB
Perl
#!/usr/bin/env perl
|
|
# TestHelpers.pm: Common routines for use in tests.
|
|
# See license information at end of file.
|
|
#
|
|
# For a cleaner view of the documentation, run
|
|
# perldoc TestHelpers.pm
|
|
# (on Ubuntu, you may need to install the perl-doc package first.)
|
|
#
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
#
|
|
# This file uses core Perl modules only.
|
|
|
|
=head1 NAME
|
|
|
|
TestHelpers - Common routines for use in tests
|
|
|
|
=head1 SYNOPSIS
|
|
|
|
C<use TestHelpers;>, and all the functions below will be exported.
|
|
TestHelpers also turns on L<strict> and L<warnings>.
|
|
|
|
=cut
|
|
|
|
package TestHelpers;
|
|
|
|
use strict;
|
|
use warnings;
|
|
use autodie; # note: still need to check system() calls manually
|
|
|
|
use Cwd qw(abs_path);
|
|
use File::Spec;
|
|
use File::Temp 0.14 qw(tempdir);
|
|
use FindBin;
|
|
use IO::Select;
|
|
use IPC::Open3;
|
|
use Symbol;
|
|
|
|
use Test::More;
|
|
|
|
# Automatically export all the functions listed in @EXPORT. The functions
|
|
# in @EXPORT_OK can be exported on request.
|
|
use parent 'Exporter';
|
|
our (@EXPORT, @EXPORT_OK, %EXPORT_TAGS);
|
|
BEGIN {
|
|
@EXPORT = qw(sibling_abs_path find_program run_program ok_or_die
|
|
run_produces_ok http_request_ok MUST_SUCCEED);
|
|
@EXPORT_OK = qw(line_mark_string);
|
|
%EXPORT_TAGS = (
|
|
all => [@EXPORT, @EXPORT_OK],
|
|
);
|
|
} #BEGIN
|
|
|
|
# Forwards for internal functions
|
|
sub line_mark_string;
|
|
|
|
# ===========================================================================
|
|
|
|
=head1 CONSTANTS
|
|
|
|
=head2 MUST_SUCCEED
|
|
|
|
True. So that calls to L</run_produces_ok> will be more self-explanatory.
|
|
|
|
=cut
|
|
|
|
use constant MUST_SUCCEED => !!1;
|
|
|
|
=head1 FUNCTIONS
|
|
|
|
These are helper routines that generally perform specific tasks.
|
|
|
|
=head2 sibling_abs_path
|
|
|
|
Return the absolute path of a file or directory in the same directory as
|
|
this file. Usage:
|
|
|
|
$path = sibling_abs_path('name');
|
|
|
|
=cut
|
|
|
|
sub sibling_abs_path {
|
|
return File::Spec->rel2abs(File::Spec->catfile($FindBin::Bin, @_));
|
|
} #sibling_abs_path()
|
|
|
|
=head2 find_program
|
|
|
|
Looks for a program in the parent directory of this script.
|
|
Usage:
|
|
|
|
$path = find_program(['subdir', ]'program name')
|
|
|
|
=cut
|
|
|
|
sub find_program {
|
|
my $pgm_file = pop; # Last arg
|
|
my @pgm_dirs = @_; # Any args before the last are additional dirs.
|
|
|
|
my ($my_vol, $my_dirs, undef) = File::Spec->splitpath($FindBin::Bin, 1); # 1 => is a dir
|
|
|
|
# Go up to the parent of the directory holding this file
|
|
my @my_dirs = File::Spec->splitdir($my_dirs);
|
|
die "Cannot run from the root directory" unless @my_dirs >= 2;
|
|
pop @my_dirs;
|
|
my $dest_dirs = File::Spec->catdir(@my_dirs, @pgm_dirs);
|
|
|
|
return File::Spec->catpath($my_vol, $dest_dirs, $pgm_file);
|
|
} #find_program()
|
|
|
|
=head2 run_program
|
|
|
|
Print a command, then run it. Can be used three ways:
|
|
|
|
=over
|
|
|
|
=item In void context
|
|
|
|
Returns if system() and the command succeed, dies otherwise. Usage:
|
|
|
|
run_program('program', 'arg1', ...);
|
|
|
|
=item In scalar context
|
|
|
|
Returns true if system() and the command succeed, false otherwise. Usage:
|
|
|
|
my $ok = run_program('program', 'arg1', ...);
|
|
|
|
=item In list context
|
|
|
|
Returns the exit status, stdout, and stderr. Usage:
|
|
|
|
my ($exit_status, $lrStdout, $lrStderr) = run_program('program', 'arg1', ...);
|
|
# Returns the shell exit status, stdout text, and stderr text.
|
|
|
|
C<$lrStdout> and C<$lrStderr> are references to the lists of output lines
|
|
on the respective handles.
|
|
|
|
C<$exit_status> is C<128+signal> if the process was killed by C<signal>,
|
|
for consistency with bash (L<https://tldp.org/LDP/abs/html/exitcodes.html>).
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
sub _run_and_capture; # forward
|
|
sub _check_queries; # forward
|
|
|
|
sub run_program {
|
|
|
|
if(wantarray) {
|
|
goto &_run_and_capture;
|
|
}
|
|
|
|
my $errmsg;
|
|
|
|
diag "Running @_";
|
|
my $status = system(@_);
|
|
|
|
if ($status == -1) {
|
|
$errmsg = "failed to execute $_[0]: $!";
|
|
}
|
|
elsif ($status & 127) {
|
|
$errmsg = sprintf "$_[0] died with signal %d, %s coredump\n",
|
|
($status & 127), ($status & 128) ? 'with' : 'without';
|
|
}
|
|
elsif($status != 0) {
|
|
$errmsg = sprintf "$_[0] exited with value %d\n", $status >> 8;
|
|
}
|
|
else {
|
|
diag "$_[0] reported success";
|
|
}
|
|
|
|
if($errmsg) {
|
|
die $errmsg unless defined wantarray;
|
|
diag $errmsg;
|
|
}
|
|
|
|
return($status == 0);
|
|
} #run_program()
|
|
|
|
=head2 ok_or_die
|
|
|
|
Run a test, but die if it fails. Usage:
|
|
|
|
ok_or_die( <some condition>, 'description', 'what to print if it dies' )
|
|
|
|
=cut
|
|
|
|
sub ok_or_die {
|
|
my ($cond, $msg, $err_msg) = @_;
|
|
my (undef, $filename, $line) = caller;
|
|
my $retval = eval line_mark_string 1, <<EOT; # Make the error message report the caller's line number
|
|
ok(\$cond, \$msg);
|
|
EOT
|
|
die($err_msg) unless $retval;
|
|
return $retval;
|
|
} #ok_or_die()
|
|
|
|
=head2 run_produces_ok
|
|
|
|
Run a program and check whether it produces expected output.
|
|
Usage:
|
|
|
|
run_produces_ok($desc, \@program_and_args, \@conditions,
|
|
<optional> $mustSucceed, <optional> $printOutput)
|
|
|
|
The test passes if each condition in C<@conditions> is true.
|
|
If C<$mustSucceed> is true, also tests for exit status 0 and empty stderr.
|
|
If C<$printOutput> is true, prints the output of C<@program_and_args>.
|
|
|
|
=head3 Conditions that can be used any time
|
|
|
|
=over
|
|
|
|
=item *
|
|
|
|
A regex: true if the regex matches at least one line in the output of
|
|
C<@program_and_args>
|
|
|
|
=item *
|
|
|
|
C<< { not => regex } >>: true if the regex is NOT found in any line of
|
|
the output of C<@program_and_args>.
|
|
|
|
=back
|
|
|
|
=head3 Conditions for the output of C<query.py>
|
|
|
|
=over
|
|
|
|
=item *
|
|
|
|
C<< { def => regex } >>: true if the regex matches at least one line in
|
|
the "Symbol Definitions" section of the output of C<@program_and_args>.
|
|
|
|
=item *
|
|
|
|
C<< { ref => regex } >>: true if the regex matches at least one line in
|
|
the "Symbol References" section of the output of C<@program_and_args>.
|
|
|
|
=item *
|
|
|
|
C<< { doc => regex } >>: true if the regex matches at least one line in
|
|
the "Documented in" section of the output of C<@program_and_args>.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
# _run_and_capture: run a program and return its exit status and output.
|
|
# Usage:
|
|
# my ($exit_status, \@stdout, \@stderr) = run_program('program', 'arg1', ...);
|
|
|
|
sub _run_and_capture {
|
|
my ($in , $out, $err); # Filehandles
|
|
$err = Symbol::gensym;
|
|
|
|
diag "Running @_";
|
|
my $pid = open3($in, $out, $err, @_);
|
|
|
|
my (@outlines, @errlines); # Captured output
|
|
my $s = IO::Select->new;
|
|
$s->add($out);
|
|
$s->add($err);
|
|
|
|
while(my @ready = $s->can_read) {
|
|
for my $fh (@ready) {
|
|
|
|
if(eof($fh)) {
|
|
$s->remove($fh);
|
|
next;
|
|
}
|
|
|
|
if($fh == $out) {
|
|
push @outlines, scalar readline $fh;
|
|
} else {
|
|
push @errlines, scalar readline $fh;
|
|
}
|
|
}
|
|
}
|
|
|
|
waitpid $pid, 0;
|
|
my $exit_status = $?;
|
|
|
|
$exit_status = ($exit_status & 127) + 128 if $exit_status & 127; # Killed by signal
|
|
|
|
return ($exit_status, \@outlines, \@errlines);
|
|
} #_run_and_capture()
|
|
|
|
sub run_produces_ok {
|
|
my ($desc, $lrProgram, $lrConditions, $mustSucceed, $printOutput) = @_;
|
|
|
|
my ($exit_status, $outlines, $errlines) = _run_and_capture(@$lrProgram);
|
|
|
|
_check_queries($desc, $lrConditions, $mustSucceed, $printOutput, $exit_status, $outlines, $errlines);
|
|
} #run_produces_ok()
|
|
|
|
sub _check_queries {
|
|
my ($desc, $lrConditions, $mustSucceed, $printOutput, $exit_status, $lrStdout, $lrStderr) = @_;
|
|
|
|
my @outlines = @$lrStdout;
|
|
my @errlines = @$lrStderr;
|
|
|
|
if ($printOutput) {
|
|
diag "@outlines";
|
|
}
|
|
|
|
# Check for and report Python errors
|
|
foreach(@outlines, @errlines) {
|
|
if (/^.*?(\S+)\s+contains the description of this error/) {
|
|
my $logfn = $1;
|
|
|
|
no autodie;
|
|
open my $logfh, '<', $logfn
|
|
or warn("Could not open Python log file $logfn: $!"), last;
|
|
my $logtext = do { local $/; <$logfh> };
|
|
close $logfh;
|
|
|
|
diag "Python error log $logfn:\n$logtext";
|
|
last;
|
|
}
|
|
}
|
|
|
|
# Basic checks
|
|
if($mustSucceed) {
|
|
eval line_mark_string 2, <<'EOT';
|
|
cmp_ok($exit_status, '==', 0, "$desc: exit status 0");
|
|
cmp_ok(@errlines, '==', 0, "$desc: stderr empty");
|
|
EOT
|
|
die $@ if $@;
|
|
}
|
|
|
|
# Check regexes
|
|
my %query_py_output; # filled in only if we see a def/ref/doc
|
|
for my $entry (@$lrConditions) {
|
|
my ($re, $negated, $source) = _parse_condition($entry);
|
|
|
|
# Parse query.py output if we need it and haven't done so
|
|
%query_py_output = _parseq(@outlines)
|
|
if $source ne 'output' && !%query_py_output;
|
|
|
|
# Build a line of test code to run
|
|
my $test = 'ok( ';
|
|
$test .= '!' if $negated;
|
|
$test .= '(grep { m{$re} } ';
|
|
if($source eq 'output') {
|
|
$test .= '@outlines';
|
|
} else {
|
|
$test .= '@{$query_py_output{' . $source . '}}';
|
|
}
|
|
$test .= '), "$desc: ' . $source;
|
|
$test .= ($negated ? ' excludes ' : ' includes ') . "\Q$re\E" . '");';
|
|
|
|
# Run it
|
|
eval line_mark_string 2, $test;
|
|
die $@ if $@;
|
|
} #foreach $entry
|
|
|
|
} #run_produces_ok()
|
|
|
|
=head2 http_request_ok
|
|
|
|
Run C<web.py> against a given path and check whether it produces expected
|
|
output. Usage:
|
|
|
|
http_request_ok($desc, $tenv, $path, \@conditions, <optional> $printOutput)
|
|
|
|
The test passes if the HTTP request succeeds, and if each condition in
|
|
C<@conditions> is true of the result (headers and body).
|
|
|
|
C<$tenv> is a L<TestEnvironment>.
|
|
|
|
C<$path> is the path part of the URL, e.g., C</testproj/latest/source>.
|
|
|
|
See L</run_produces_ok> for C<@conditions>.
|
|
|
|
If C<$printOutput> is true, prints the output of C<@program_and_args>.
|
|
|
|
=cut
|
|
|
|
sub http_request_ok {
|
|
my ($desc, $tenv, $path, $lrConditions, $printOutput) = @_;
|
|
die "Invalid args" unless $desc && ref $tenv && eval { @$lrConditions };
|
|
|
|
my ($exit_status, $lrStdout, $lrStderr) = $tenv->make_web_request($path);
|
|
|
|
_check_queries($desc, $lrConditions, MUST_SUCCEED, $printOutput,
|
|
$exit_status, $lrStdout, $lrStderr);
|
|
} #http_request_ok()
|
|
|
|
=head1 INTERNAL FUNCTIONS
|
|
|
|
These are ones you probably won't need to call.
|
|
|
|
=head2 _parseq
|
|
|
|
Parse the output of query.py. Usage:
|
|
|
|
%parsed = _parseq(@lines_of_output);
|
|
|
|
=cut
|
|
|
|
sub _parseq {
|
|
my %retval = ( def => [], ref => [], doc => [] );
|
|
my $list;
|
|
foreach(@_) {
|
|
chomp;
|
|
if(/(?:^Symbol Definitions:$)|\bDefined in \d+/) {
|
|
$list = 'def';
|
|
next;
|
|
} elsif(/(?:^Symbol References:$)|\bReferenced in \d+/) {
|
|
$list = 'ref';
|
|
next;
|
|
} elsif(/(?:^Documented in:$)|\bDocumented in \d+/) {
|
|
$list = 'doc';
|
|
next;
|
|
}
|
|
next unless $list;
|
|
|
|
#diag "Adding `$_' to list $list";
|
|
push @{$retval{$list}}, $_;
|
|
}
|
|
return %retval;
|
|
} #_parseq()
|
|
|
|
=head2 _parse_condition
|
|
|
|
Parse a condition for L</run_produces_ok>. Usage:
|
|
|
|
($regex, $negated, $source) = _parse_condition($entry[, $source]);
|
|
|
|
=cut
|
|
|
|
sub _parse_condition {
|
|
my ($entry, $source_in) = @_;
|
|
my ($regex, $negated, $source); # Return values
|
|
|
|
# Basic cases
|
|
if(ref $entry eq 'Regexp') {
|
|
return ($entry, 0, $source_in || 'output');
|
|
} elsif(ref $entry eq 'HASH' && ref $entry->{not} eq 'Regexp') {
|
|
return ($entry->{not}, 1, $source_in || 'output');
|
|
}
|
|
|
|
# Sub-keys: chain
|
|
if(ref $entry eq 'HASH' && scalar keys %{$entry} == 1) {
|
|
return _parse_condition((values %{$entry})[0], (keys %{$entry})[0]);
|
|
}
|
|
|
|
# If we get here, we don't know how to handle it
|
|
die "Invalid entry $entry";
|
|
} #_parse_condition()
|
|
|
|
|
|
=head2 _croak
|
|
|
|
Lazy invoker for L<Carp/croak>.
|
|
|
|
=cut
|
|
|
|
sub _croak {
|
|
require Carp;
|
|
goto &Carp::croak;
|
|
} #_croak()
|
|
|
|
=head2 line_mark_string
|
|
|
|
Add a C<#line> directive to a string. Usage:
|
|
|
|
To use the caller's filename and line number:
|
|
|
|
my $str = line_mark_string <<EOT ;
|
|
$contents
|
|
EOT
|
|
|
|
To use a filename and line number from higher in the call stack:
|
|
|
|
my $str = line_mark_string $level, <<EOT ;
|
|
$contents
|
|
EOT
|
|
|
|
To use a specified filename and line number:
|
|
|
|
my $str = line_mark_string __FILE__, __LINE__, <<EOT ;
|
|
$contents
|
|
EOT
|
|
|
|
In the first and second forms, information from C<caller> will be used for the
|
|
filename and line number.
|
|
|
|
In the first and third forms, the C<#line> directive will point to the line
|
|
after the C<line_mark_string> invocation, i.e., the first line of <C$contents>.
|
|
Generally, C<$contents> will be source code, although this is not required.
|
|
|
|
C<$contents> must be defined, but can be empty.
|
|
|
|
=cut
|
|
|
|
sub line_mark_string {
|
|
my ($contents, $filename, $line);
|
|
|
|
if(@_ == 1) {
|
|
$contents = $_[0];
|
|
(undef, $filename, $line) = caller;
|
|
++$line;
|
|
} elsif(@_ == 2) {
|
|
(undef, $filename, $line) = caller($_[0]);
|
|
$contents = $_[1];
|
|
} elsif(@_ == 3) {
|
|
($filename, $line, $contents) = @_;
|
|
++$line;
|
|
} else {
|
|
_croak "Invalid invocation";
|
|
}
|
|
|
|
_croak "Need text" unless defined $contents;
|
|
die "Couldn't get location information" unless $filename && $line;
|
|
|
|
$filename =~ s/"/-/g;
|
|
|
|
return <<EOT;
|
|
#line $line "$filename"
|
|
$contents
|
|
EOT
|
|
} #line_mark_string()
|
|
|
|
=head2 import
|
|
|
|
Set up. Called automatically. Activates L<strict> and L<warnings>
|
|
in the caller.
|
|
|
|
=cut
|
|
|
|
sub import {
|
|
__PACKAGE__->export_to_level(1, @_);
|
|
strict->import;
|
|
warnings->import;
|
|
} #import()
|
|
|
|
1;
|
|
__END__
|
|
|
|
=head1 AUTHOR
|
|
|
|
Christopher White, C<< <cwhite@d3engineering.com> >>
|
|
|
|
=head1 COPYRIGHT
|
|
|
|
Copyright (c) 2020 D3 Engineering, LLC.
|
|
|
|
Elixir is free software; you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
Elixir is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with Elixir. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
=cut
|