blob: 94d9d553b4e04d2b167046f6c398bed66afb79f8 [file] [log] [blame]
package NiHTest;
use strict;
use warnings;
use Cwd;
use File::Copy;
use File::Path qw(mkpath);
use IPC::Open3;
use Symbol 'gensym';
use UNIVERSAL;
use Data::Dumper qw(Dumper);
use Text::Diff;
# NiHTest -- package to run regression tests
# Copyright (C) 2002-2014 Dieter Baron and Thomas Klausner
#
# This file is part of ckmame, a program to check rom sets for MAME.
# The authors can be contacted at <ckmame@nih.at>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in
# the documentation and/or other materials provided with the
# distribution.
# 3. The names of the authors may not be used to endorse or promote
# products derived from this software without specific prior
# written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
# IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# runtest TESTNAME
#
# files:
# TESTNAME.test: test scenario
#
# test scenario:
# Lines beginning with # are comments.
#
# The following commands are recognized; return and args must
# appear exactly once, the others are optional.
#
# args ARGS
# run program with command line arguments ARGS
#
# description TEXT
# description of what test is for
#
# features FEATURE ...
# only run test if all FEATUREs are present, otherwise skip it.
#
# file TEST IN OUT
# copy file IN as TEST, compare against OUT after program run.
#
# file-del TEST IN
# copy file IN as TEST, check that it is removed by program.
#
# file-new TEST OUT
# check that file TEST is created by program and compare
# against OUT.
#
# mkdir MODE NAME
# create directory NAME with permissions MODE.
#
# pipein COMMAND ARGS ...
# pipe output of running COMMAND to program's stdin.
#
# preload LIBRARY
# pre-load LIBRARY before running program.
#
# program PRG
# run PRG instead of ckmame.
#
# return RET
# RET is the expected exit code
#
# setenv VAR VALUE
# set environment variable VAR to VALUE.
#
# stderr TEXT
# program is expected to produce the error message TEXT. If
# multiple stderr commands are used, the messages are
# expected in the order given.
#
# stderr-replace REGEX REPLACEMENT
# run regex replacement over expected and got stderr output.
#
# stdout TEXT
# program is expected to print TEXT to stdout. If multiple
# stdout commands are used, the messages are expected in
# the order given.
#
# touch MTIME FILE
# set last modified timestamp of FILE to MTIME (seconds since epoch).
# If FILE doesn't exist, an empty file is created.
#
# ulimit C VALUE
# set ulimit -C to VALUE while running the program.
#
# exit status
# runtest uses the following exit codes:
# 0: test passed
# 1: test failed
# 2: other error
# 77: test was skipped
#
# environment variables:
# RUN_GDB: if set, run gdb on program in test environment
# KEEP_BROKEN: if set, don't delete test environment if test failed
# NO_CLEANUP: if set, don't delete test environment
# SETUP_ONLY: if set, exit after creating test environment
# VERBOSE: if set, be more verbose (e. g., output diffs)
my %EXIT_CODES = (
PASS => 0,
FAIL => 1,
SKIP => 77,
ERROR => 99
);
sub new {
my $class = UNIVERSAL::isa ($_[0], __PACKAGE__) ? shift : __PACKAGE__;
my $self = bless {}, $class;
my ($opts) = @_;
$self->{default_program} = $opts->{default_program};
$self->{zipcmp} = $opts->{zipcmp} // 'zipcmp';
$self->{zipcmp_flags} = $opts->{zipcmp_flags};
$self->{directives} = {
args => { type => 'string...', once => 1, required => 1 },
description => { type => 'string', once => 1 },
features => { type => 'string...', once => 1 },
file => { type => 'string string string' },
'file-del' => { type => 'string string' },
'file-new' => { type => 'string string' },
mkdir => { type => 'string string' },
pipein => { type => 'string', once => 1 },
preload => { type => 'string', once => 1 },
program => { type => 'string', once => 1 },
'return' => { type => 'int', once => 1, required => 1 },
setenv => { type => 'string string' },
stderr => { type => 'string' },
'stderr-replace' => { type => 'string string' },
stdout => { type => 'string' },
touch => { type => 'int string' },
ulimit => { type => 'char string' }
};
$self->{compare_by_type} = {};
$self->{copy_by_type} = {};
$self->{hooks} = {};
$self->add_comparator('zip/zip', \&comparator_zip);
$self->{srcdir} = $opts->{srcdir} // $ENV{srcdir};
if (!defined($self->{srcdir}) || $self->{srcdir} eq '') {
$self->{srcdir} = `sed -n 's/^srcdir = \(.*\)/\1/p' Makefile`;
chomp($self->{srcdir});
}
$self->{in_sandbox} = 0;
$self->{verbose} = $ENV{VERBOSE};
$self->{keep_broken} = $ENV{KEEP_BROKEN};
$self->{no_cleanup} = $ENV{NO_CLEANUP};
$self->{setup_only} = $ENV{SETUP_ONLY};
return $self;
}
sub add_comparator {
my ($self, $ext, $sub) = @_;
return $self->add_file_proc('compare_by_type', $ext, $sub);
}
sub add_copier {
my ($self, $ext, $sub) = @_;
return $self->add_file_proc('copy_by_type', $ext, $sub);
}
sub add_directive {
my ($self, $name, $def) = @_;
if (exists($self->{directives}->{$name})) {
$self->die("directive $name already defined");
}
# TODO: validate $def
$self->{directives}->{$name} = $def;
return 1;
}
sub add_file_proc {
my ($self, $proc, $ext, $sub) = @_;
$self->{$proc}->{$ext} = [] unless (defined($self->{$proc}->{$ext}));
unshift @{$self->{$proc}->{$ext}}, $sub;
return 1;
}
sub add_hook {
my ($self, $hook, $sub) = @_;
$self->{hooks}->{$hook} = [] unless (defined($self->{hooks}->{$hook}));
push @{$self->{hooks}->{$hook}}, $sub;
return 1;
}
sub end {
my ($self, @results) = @_;
my $result = 'PASS';
for my $r (@results) {
if ($r eq 'ERROR' || ($r eq 'FAIL' && $result ne 'ERROR')) {
$result = $r;
}
}
$self->end_test($result);
}
sub run {
my ($self, @argv) = @_;
$self->setup(@argv);
$self->end($self->runtest());
}
sub runtest {
my ($self, $tag) = @_;
$ENV{TZ} = "UTC";
$ENV{LC_CTYPE} = "C";
$self->sandbox_create($tag);
$self->sandbox_enter();
my $ok = 1;
$ok &= $self->copy_files();
$ok &= $self->run_hook('post_copy_files');
$ok &= $self->touch_files();
$ok &= $self->run_hook('prepare_sandbox');
return 'ERROR' unless ($ok);
if ($self->{setup_only}) {
$self->sandbox_leave();
return 'SKIP';
}
for my $env (@{$self->{test}->{'setenv'}}) {
$ENV{$env->[0]} = $env->[1];
}
if (defined($self->{test}->{'preload'})) {
$ENV{LD_PRELOAD} = cwd() . "/../.libs/$self->{test}->{'preload'}";
}
$self->run_program();
for my $env (@{$self->{test}->{'setenv'}}) {
delete ${ENV{$env->[0]}};
}
if (defined($self->{test}->{'preload'})) {
delete ${ENV{LD_PRELOAD}};
}
if ($self->{test}->{stdout}) {
$self->{expected_stdout} = [ @{$self->{test}->{stdout}} ];
}
else {
$self->{expected_stdout} = [];
}
if ($self->{test}->{stderr}) {
$self->{expected_stderr} = [ @{$self->{test}->{stderr}} ];
}
else {
$self->{expected_stderr} = [];
}
$self->run_hook('post_run_program');
my @failed = ();
if ($self->{exit_status} != $self->{test}->{return} // 0) {
push @failed, 'exit status';
if ($self->{verbose}) {
print "Unexpected exit status:\n";
print "-" . ($self->{test}->{return} // 0) . "\n+$self->{exit_status}\n";
}
}
if (!$self->compare_arrays($self->{expected_stdout}, $self->{stdout}, 'output')) {
push @failed, 'output';
}
if (!$self->compare_arrays($self->{expected_stderr}, $self->{stderr}, 'error output')) {
push @failed, 'error output';
}
if (!$self->compare_files()) {
push @failed, 'files';
}
$self->{failed} = \@failed;
$self->run_hook('checks');
my $result = scalar(@{$self->{failed}}) == 0 ? 'PASS' : 'FAIL';
$self->sandbox_leave();
if (!($self->{no_cleanup} || ($self->{keep_broken} && $result eq 'FAIL'))) {
$self->sandbox_remove();
}
$self->print_test_result($tag, $result, join ', ', @{$self->{failed}});
return $result;
}
sub setup {
my ($self, @argv) = @_;
if (scalar(@argv) != 1) {
print STDERR "Usage: $0 testcase\n";
exit(1);
}
my $testcase = shift @argv;
$testcase .= '.test' unless ($testcase =~ m/\.test$/);
my $testcase_file = $self->find_file($testcase);
$self->die("cannot find test case $testcase") unless ($testcase_file);
$testcase =~ s,^(?:.*/)?([^/]*)\.test$,$1,;
$self->{testname} = $testcase;
$self->die("error in test case definition") unless $self->parse_case($testcase_file);
$self->check_features_requirement() if ($self->{test}->{features});
}
#
# internal methods
#
sub add_file {
my ($self, $file) = @_;
if (defined($self->{files}->{$file->{destination}})) {
$self->warn("duplicate specification for input file $file->{destination}");
return undef;
}
$self->{files}->{$file->{destination}} = $file;
return 1;
}
sub check_features_requirement() {
my ($self) = @_;
### TODO: implement
return 1;
}
sub comparator_zip {
my ($self, $got, $expected) = @_;
my @args = ($self->{zipcmp}, $self->{verbose} ? '-v' : '-q');
push @args, $self->{zipcmp_flags} if ($self->{zipcmp_flags});
push @args, ($expected, $got);
my $ret = system(@args);
return $ret == 0;
}
sub compare_arrays() {
my ($self, $a, $b, $tag) = @_;
my $ok = 1;
if (scalar(@$a) != scalar(@$b)) {
$ok = 0;
}
else {
for (my $i = 0; $i < scalar(@$a); $i++) {
if ($a->[$i] ne $b->[$i]) {
$ok = 0;
last;
}
}
}
if (!$ok && $self->{verbose}) {
print "Unexpected $tag:\n";
print "--- expected\n+++ got\n";
my @a = map { $_ . "\n"; } @$a;
my @b = map { $_ . "\n"; } @$b;
print diff(\@a, \@b);
}
return $ok;
}
sub compare_file() {
my ($self, $got, $expected) = @_;
my $real_expected = $self->find_file($expected);
unless ($real_expected) {
$self->warn("cannot find expected result file $expected");
return 0;
}
my $ok = $self->run_comparator($got, $real_expected);
if (!defined($ok)) {
my $ret;
if ($self->{verbose}) {
$ret = system('diff', '-u', $real_expected, $got);
}
else {
$ret = system('cmp', '-s', $real_expected, $got);
}
$ok = ($ret == 0);
}
return $ok;
}
sub compare_files() {
my ($self) = @_;
my $ok = 1;
my $ls;
open $ls, "find . -type f -print |";
unless ($ls) {
# TODO: handle error
}
my @files_got = ();
while (my $line = <$ls>) {
chomp $line;
$line =~ s,^\./,,;
push @files_got, $line;
}
close($ls);
@files_got = sort @files_got;
my @files_should = ();
for my $file (sort keys %{$self->{files}}) {
push @files_should, $file if ($self->{files}->{$file}->{result} || $self->{files}->{$file}->{ignore});
}
$self->{files_got} = \@files_got;
$self->{files_should} = \@files_should;
unless ($self->run_hook('post_list_files')) {
return 0;
}
$ok = $self->compare_arrays($self->{files_should}, $self->{files_got}, 'files');
for my $file (@{$self->{files_got}}) {
my $file_def = $self->{files}->{$file};
next unless ($file_def && $file_def->{result});
$ok &= $self->compare_file($file, $file_def->{result});
}
return $ok;
}
sub copy_files {
my ($self) = @_;
my $ok = 1;
for my $filename (sort keys %{$self->{files}}) {
my $file = $self->{files}->{$filename};
next unless ($file->{source});
my $src = $self->find_file($file->{source});
unless ($src) {
$self->warn("cannot find input file $file->{source}");
$ok = 0;
next;
}
if ($file->{destination} =~ m,/,) {
my $dir = $file->{destination};
$dir =~ s,/[^/]*$,,;
if (! -d $dir) {
mkpath($dir);
}
}
my $this_ok = $self->run_copier($src, $file->{destination});
if (defined($this_ok)) {
$ok &= $this_ok;
}
else {
unless (copy($src, $file->{destination})) {
$self->warn("cannot copy $src to $file->{destination}: $!");
$ok = 0;
}
}
}
if (defined($self->{test}->{mkdir})) {
for my $dir_spec (@{$self->{test}->{mkdir}}) {
my ($mode, $dir) = @$dir_spec;
if (! -d $dir) {
unless (mkdir($dir, oct($mode))) {
$self->warn("cannot create directory $dir: $!");
$ok = 0;
}
}
}
}
$self->die("failed to copy input files") unless ($ok);
}
sub die() {
my ($self, $msg) = @_;
print STDERR "$0: $msg\n" if ($msg);
$self->end_test('ERROR');
}
sub end_test {
my ($self, $status) = @_;
my $exit_code = $EXIT_CODES{$status} // $EXIT_CODES{ERROR};
$self->exit($exit_code);
}
sub exit() {
my ($self, $status) = @_;
### TODO: cleanup
exit($status);
}
sub find_file() {
my ($self, $fname) = @_;
for my $dir (('', "$self->{srcdir}/")) {
my $f = "$dir$fname";
$f = "../$f" if ($self->{in_sandbox} && $dir !~ m,^/,);
return $f if (-f $f);
}
return undef;
}
sub get_extension {
my ($self, $fname) = @_;
my $ext = $fname;
if ($ext =~ m/\./) {
$ext =~ s/.*\.//;
}
else {
$ext = '';
}
return $ext;
}
sub parse_args {
my ($self, $type, $str) = @_;
if ($type =~ m/(\s|\.\.\.$)/) {
my $ellipsis = 0;
if ($type =~ m/(.*)\.\.\.$/) {
$ellipsis = 1;
$type = $1;
}
my @types = split /\s+/, $type;
my @strs = split /\s+/, $str;
if (!$ellipsis && scalar(@types) != scalar(@strs)) {
$self->warn_file_line("expected " . (scalar(@types)) . " arguments, got " . (scalar(@strs)));
return undef;
}
my $args = [];
my $n = scalar(@types);
for (my $i=0; $i<scalar(@strs); $i++) {
my $val = $self->parse_args(($i >= $n ? $types[$n-1] : $types[$i]), $strs[$i]);
return undef unless (defined($val));
push @$args, $val;
}
return $args;
}
else {
if ($type eq 'string') {
return $str;
}
elsif ($type eq 'int') {
if ($str !~ m/^\d+$/) {
$self->warn_file_line("illegal int [$str]");
return undef;
}
return $str+0;
}
elsif ($type eq 'char') {
if ($str !~ m/^.$/) {
$self->warn_file_line("illegal char [$str]");
return undef;
}
return $str;
}
else {
$self->warn_file_line("unknown type $type");
return undef;
}
}
}
sub parse_case() {
my ($self, $fname) = @_;
my $ok = 1;
open TST, "< $fname" or $self->die("cannot open test case $fname: $!");
$self->{testcase_fname} = $fname;
my %test = ();
while (my $line = <TST>) {
chomp $line;
next if ($line =~ m/^\#/);
unless ($line =~ m/(\S*)(?:\s(.*))?/) {
$self->warn_file_line("cannot parse line $line");
$ok = 0;
next;
}
my ($cmd, $argstring) = ($1, $2//"");
my $def = $self->{directives}->{$cmd};
unless ($def) {
$self->warn_file_line("unknown directive $cmd in test file");
$ok = 0;
next;
}
my $args = $self->parse_args($def->{type}, $argstring);
next unless (defined($args));
if ($def->{once}) {
if (defined($test{$cmd})) {
$self->warn_file_line("directive $cmd appeared twice in test file");
}
$test{$cmd} = $args;
}
else {
$test{$cmd} = [] unless (defined($test{$cmd}));
push @{$test{$cmd}}, $args;
}
}
close TST;
return undef unless ($ok);
for my $cmd (sort keys %test) {
if ($self->{directives}->{$cmd}->{required} && !defined($test{$cmd})) {
$self->warn_file("required directive $cmd missing in test file");
$ok = 0;
}
}
return undef unless ($ok);
if (defined($test{'stderr-replace'}) && defined($test{stderr})) {
$test{stderr} = [ map { $self->stderr_rewrite($test{'stderr-replace'}, $_); } @{$test{stderr}} ];
}
if (!defined($test{program})) {
$test{program} = $self->{default_program};
}
$self->{test} = \%test;
$self->run_hook('mangle_program');
if (!$self->parse_postprocess_files()) {
return 0;
}
return $self->run_hook('post_parse');
}
sub parse_postprocess_files {
my ($self) = @_;
$self->{files} = {};
my $ok = 1;
for my $file (@{$self->{test}->{file}}) {
$ok = 0 unless ($self->add_file({ source => $file->[1], destination => $file->[0], result => $file->[2] }));
}
for my $file (@{$self->{test}->{'file-del'}}) {
$ok = 0 unless ($self->add_file({ source => $file->[1], destination => $file->[0], result => undef }));
}
for my $file (@{$self->{test}->{'file-new'}}) {
$ok = 0 unless ($self->add_file({ source => undef, destination => $file->[0], result => $file->[1] }));
}
return $ok;
}
sub print_test_result {
my ($self, $tag, $result, $reason) = @_;
if ($self->{verbose}) {
print "$self->{testname}";
print " ($tag)" if ($tag);
print " -- $result";
print ": $reason" if ($reason);
print "\n";
}
}
sub run_comparator {
my ($self, $got, $expected) = @_;
return $self->run_file_proc('compare_by_type', $got, $expected);
}
sub run_copier {
my ($self, $src, $dest) = @_;
return $self->run_file_proc('copy_by_type', $src, $dest);
}
sub run_file_proc {
my ($self, $proc, $got, $expected) = @_;
my $ext = ($self->get_extension($got)) . '/' . ($self->get_extension($expected));
if (defined($self->{$proc}->{$ext})) {
for my $sub (@{$self->{$proc}->{$ext}}) {
my $ret = $sub->($self, $got, $expected);
return $ret if (defined($ret));
}
}
return undef;
}
sub run_hook {
my ($self, $hook) = @_;
my $ok = 1;
if (defined($self->{hooks}->{$hook})) {
for my $sub (@{$self->{hooks}->{$hook}}) {
unless ($sub->($self, $hook)) {
$self->warn("hook $hook failed");
$ok = 0;
}
}
}
return $ok;
}
sub run_program {
my ($self) = @_;
my ($stdin, $stdout, $stderr);
$stderr = gensym;
my $cmd = '../' . $self->{test}->{program} . " " . (join ' ', @{$self->{test}->{args}});
### TODO: catch errors?
my $pid = open3($stdin, $stdout, $stderr, $cmd);
$self->{stdout} = [];
$self->{stderr} = [];
if ($self->{test}->{pipein}) {
my $fh;
open($fh, "$self->{test}->{pipein} |");
if (!defined($fh)) {
$self->die("cannot run pipein command [$self->{test}->{pipein}: $!");
}
while (my $line = <$fh>) {
print $stdin $line;
}
close($fh);
close($stdin);
}
while (my $line = <$stdout>) {
chomp $line;
push @{$self->{stdout}}, $line;
}
my $prg = $self->{test}->{program};
$prg =~ s,.*/,,;
while (my $line = <$stderr>) {
chomp $line;
$line =~ s/^[^: ]*$prg: //;
if (defined($self->{test}->{'stderr-replace'})) {
$line = $self->stderr_rewrite($self->{test}->{'stderr-replace'}, $line);
}
push @{$self->{stderr}}, $line;
}
waitpid($pid, 0);
$self->{exit_status} = $? >> 8;
}
sub sandbox_create {
my ($self, $tag) = @_;
$tag = ($tag ? "-$tag" : "");
$self->{sandbox_dir} = "sandbox-$self->{testname}$tag.d$$";
$self->die("sandbox $self->{sandbox_dir} already exists") if (-e $self->{sandbox_dir});
mkdir($self->{sandbox_dir}) or $self->die("cannot create sandbox $self->{sandbox_dir}: $!");
return 1;
}
sub sandbox_enter {
my ($self) = @_;
$self->die("internal error: cannot enter sandbox before creating it") unless (defined($self->{sandbox_dir}));
return if ($self->{in_sandbox});
chdir($self->{sandbox_dir}) or $self->die("cant cd into sandbox $self->{sandbox_dir}: $!");
$self->{in_sandbox} = 1;
}
sub sandbox_leave {
my ($self) = @_;
return if (!$self->{in_sandbox});
chdir('..') or $self->die("cannot leave sandbox: $!");
$self->{in_sandbox} = 0;
}
sub sandbox_remove {
my ($self) = @_;
my $ok = 1;
unless (system('chmod', '-R', 'u+rwx', $self->{sandbox_dir}) == 0) {
$self->warn("can't ensure that sandbox is writable: $!");
}
unless (system('rm', '-rf', $self->{sandbox_dir}) == 0) {
$self->warn("can't remove sandbox: $!");
$ok = 0;
}
return $ok;
}
sub touch_files {
my ($self) = @_;
my $ok = 1;
if (defined($self->{test}->{touch})) {
for my $args (@{$self->{test}->{touch}}) {
my ($mtime, $fname) = @$args;
if (!-f $fname) {
my $fh;
unless (open($fh, "> $fname") and close($fh)) {
# TODO: error message
$ok = 0;
next;
}
}
unless (utime($mtime, $mtime, $fname) == 1) {
# TODO: error message
$ok = 0;
}
}
}
return $ok;
}
sub warn {
my ($self, $msg) = @_;
print STDERR "$0: $msg\n";
}
sub warn_file {
my ($self, $msg) = @_;
$self->warn("$self->{testcase_fname}: $msg");
}
sub warn_file_line {
my ($self, $msg) = @_;
$self->warn("$self->{testcase_fname}:$.: $msg");
}
sub stderr_rewrite {
my ($self, $pattern, $line) = @_;
for my $repl (@{$pattern}) {
$line =~ s/$repl->[0]/$repl->[1]/;
}
return $line;
}
1;