package Test::Script; # ABSTRACT: Basic cross-platform tests for scripts our $VERSION = '1.23'; # VERSION use 5.008001; use strict; use warnings; use Carp qw( croak ); use Exporter; use File::Spec; use File::Spec::Unix; use Probe::Perl; use Capture::Tiny qw( capture ); use Test2::API qw( context ); use File::Temp qw( tempdir ); use IO::Handle; our @ISA = 'Exporter'; our @EXPORT = qw{ script_compiles script_compiles_ok script_runs script_stdout_is script_stdout_isnt script_stdout_like script_stdout_unlike script_stderr_is script_stderr_isnt script_stderr_like script_stderr_unlike }; sub import { my $self = shift; my $pack = caller; if(defined $_[0] && $_[0] =~ /^(?:no_plan|skip_all|tests)$/) { # icky back compat. # do not use. my $ctx = context(); if($_[0] eq 'tests') { $ctx->plan($_[1]); } elsif($_[0] eq 'skip_all') { $ctx->plan(0, 'SKIP', $_[1]); } else { $ctx->hub->plan('NO PLAN'); } $ctx->release; } foreach ( @EXPORT ) { $self->export_to_level(1, $self, $_); } } my $perl = undef; sub perl () { $perl or $perl = Probe::Perl->find_perl_interpreter; } sub path ($) { my $path = shift; unless ( defined $path ) { croak("Did not provide a script name"); } if ( File::Spec::Unix->file_name_is_absolute($path) ) { croak("Script name must be relative"); } File::Spec->catfile( File::Spec->curdir, split /\//, $path ); } ##################################################################### # Test Functions sub script_compiles { my $args = _script(shift); my $unix = shift @$args; my $path = path( $unix ); my $pargs = _perl_args($path); my $dir = _preload_module(); my $cmd = [ perl, @$pargs, "-I$dir", '-M__TEST_SCRIPT__', '-c', $path, @$args ]; my ($stdout, $stderr) = capture { system(@$cmd) }; my $error = $@; my $exit = $? ? ($? >> 8) : 0; my $signal = $? ? ($? & 127) : 0; my $ok = !! ( $error eq '' and $exit == 0 and $signal == 0 and $stderr =~ /syntax OK\s+\z/si ); my $ctx = context(); $ctx->ok( $ok, $_[0] || "Script $unix compiles" ); $ctx->diag( "$exit - $stderr" ) unless $ok; $ctx->diag( "exception: $error" ) if $error; $ctx->diag( "signal: $signal" ) if $signal; $ctx->release; return $ok; } # this is noticeably slower for long @INC lists (sometimes present in cpantesters # boxes) than the previous implementation, which added a -I for every element in # @INC. (also slower for more reasonable @INCs, but not noticeably). But it is # safer as very long argument lists can break calls to system sub _preload_module { my @opts = ( '.test-script-XXXXXXXX', CLEANUP => 1); if(-w File::Spec->curdir) { push @opts, DIR => File::Spec->curdir } else { push @opts, DIR => File::Spec->tmpdir } my $dir = tempdir(@opts); $dir = File::Spec->rel2abs($dir); # this is hopefully a pm file that nobody would use my $filename = File::Spec->catfile($dir, '__TEST_SCRIPT__.pm'); my $fh; open($fh, '>', $filename) || die "unable to open $filename: $!"; print($fh 'unshift @INC, ', join ',', # quotemeta is overkill, but it will make sure that characters # like " are quoted map { '"' . quotemeta($_) . '"' } grep { ! ref } @INC) || die "unable to write $filename: $!"; close($fh) || die "unable to close $filename: $!";; $dir; } my $stdout; my $stderr; sub script_runs { my $args = _script(shift); my $opt = _options(\@_); my $unix = shift @$args; my $path = path( $unix ); my $pargs = _perl_args($path); my $dir = _preload_module(); my $cmd = [ perl, @$pargs, "-I$dir", '-M__TEST_SCRIPT__', $path, @$args ]; $stdout = ''; $stderr = ''; if($opt->{stdin}) { my $filename; if(ref($opt->{stdin}) eq 'SCALAR') { $filename = File::Spec->catfile( tempdir(CLEANUP => 1), 'stdin.txt', ); my $tmp; open($tmp, '>', $filename) || die "unable to write to $filename"; print $tmp ${ $opt->{stdin} }; close $tmp; } elsif(ref($opt->{stdin}) eq '') { $filename = $opt->{stdin}; } else { croak("stdin MUST be either a scalar reference or a string filename"); } my $fh; open($fh, '<', $filename) || die "unable to open $filename $!"; STDIN->fdopen( $fh, 'r' ) or die "unable to reopen stdin to $filename $!"; } (${$opt->{stdout}}, ${$opt->{stderr}}) = capture { system(@$cmd) }; my $error = $@; my $exit = $? ? ($? >> 8) : 0; my $signal = $? ? ($? & 127) : 0; my $ok = !! ( $error eq '' and $exit == $opt->{exit} and $signal == $opt->{signal} ); my $ctx = context(); $ctx->ok( $ok, $_[0] || "Script $unix runs" ); $ctx->diag( "$exit - $stderr" ) unless $ok; $ctx->diag( "exception: $error" ) if $error; $ctx->diag( "signal: $signal" ) unless $signal == $opt->{signal}; $ctx->release; return $ok; } sub _like { my($text, $pattern, $regex, $not, $name) = @_; my $ok = $regex ? $text =~ $pattern : $text eq $pattern; $ok = !$ok if $not; my $ctx = context; $ctx->ok( $ok, $name ); unless($ok) { $ctx->diag( "The output" ); $ctx->diag( " $_") for split /\n/, $text; $ctx->diag( $not ? "does match" : "does not match" ); if($regex) { $ctx->diag( " $pattern" ); } else { $ctx->diag( " $_" ) for split /\n/, $pattern; } } $ctx->release; $ok; } sub script_stdout_is { my($pattern, $name) = @_; @_ = ($stdout, $pattern, 0, 0, $name || 'stdout matches' ); goto &_like; } sub script_stdout_isnt { my($pattern, $name) = @_; @_ = ($stdout, $pattern, 0, 1, $name || 'stdout does not match' ); goto &_like; } sub script_stdout_like { my($pattern, $name) = @_; @_ = ($stdout, $pattern, 1, 0, $name || 'stdout matches' ); goto &_like; } sub script_stdout_unlike { my($pattern, $name) = @_; @_ = ($stdout, $pattern, 1, 1, $name || 'stdout does not match' ); goto &_like; } sub script_stderr_is { my($pattern, $name) = @_; @_ = ($stderr, $pattern, 0, 0, $name || 'stderr matches' ); goto &_like; } sub script_stderr_isnt { my($pattern, $name) = @_; @_ = ($stderr, $pattern, 0, 1, $name || 'stderr does not match' ); goto &_like; } sub script_stderr_like { my($pattern, $name) = @_; @_ = ($stderr, $pattern, 1, 0, $name || 'stderr matches' ); goto &_like; } sub script_stderr_unlike { my($pattern, $name) = @_; @_ = ($stderr, $pattern, 1, 1, $name || 'stderr does not match' ); goto &_like; } ###################################################################### # Support Functions # Script params must be either a simple non-null string with the script # name, or an array reference with one or more non-null strings. sub _script { my $in = shift; if ( defined _STRING($in) ) { return [ $in ]; } if ( _ARRAY($in) ) { unless ( scalar grep { not defined _STRING($_) } @$in ) { return [ @$in ]; } } croak("Invalid command parameter"); } # Determine any extra arguments that need to be passed into Perl. # ATM this is just -T. sub _perl_args { my($script) = @_; my $fh; my $first_line = ''; if(open($fh, '<', $script)) { $first_line = <$fh>; close $fh; } (grep /^-.*T/, split /\s+/, $first_line) ? ['-T'] : []; } # Inline some basic Params::Util functions sub _options { my %options = ref($_[0]->[0]) eq 'HASH' ? %{ shift @{ $_[0] } }: (); $options{exit} = 0 unless defined $options{exit}; $options{signal} = 0 unless defined $options{signal}; my $stdin = ''; #$options{stdin} = \$stdin unless defined $options{stdin}; $options{stdout} = \$stdout unless defined $options{stdout}; $options{stderr} = \$stderr unless defined $options{stderr}; \%options; } sub _ARRAY ($) { (ref $_[0] eq 'ARRAY' and @{$_[0]}) ? $_[0] : undef; } sub _STRING ($) { (defined $_[0] and ! ref $_[0] and length($_[0])) ? $_[0] : undef; } BEGIN { # Alias to old name *script_compiles_ok = *script_compiles; } 1; __END__ =pod =encoding UTF-8 =head1 NAME Test::Script - Basic cross-platform tests for scripts =head1 VERSION version 1.23 =head1 SYNOPSIS use Test2::V0; use Test::Script; script_compiles('script/myscript.pl'); script_runs(['script/myscript.pl', '--my-argument']); done_testing; =head1 DESCRIPTION The intent of this module is to provide a series of basic tests for 80% of the testing you will need to do for scripts in the F