1437 lines
30 KiB
Perl
1437 lines
30 KiB
Perl
package NiHTest;
|
|
|
|
use strict;
|
|
use warnings;
|
|
|
|
use Cwd;
|
|
use File::Copy;
|
|
use File::Path qw(mkpath remove_tree);
|
|
use Getopt::Long qw(:config posix_default bundling no_ignore_case);
|
|
use IPC::Open3;
|
|
use Storable qw(dclone);
|
|
use Symbol 'gensym';
|
|
use UNIVERSAL;
|
|
|
|
#use Data::Dumper qw(Dumper);
|
|
|
|
# NiHTest -- package to run regression tests
|
|
# Copyright (C) 2002-2016 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.
|
|
#
|
|
# pipefile FILE
|
|
# pipe FILE to program's stdin.
|
|
#
|
|
# pipein COMMAND ARGS ...
|
|
# pipe output of running COMMAND to program's stdin.
|
|
#
|
|
# precheck COMMAND ARGS ...
|
|
# if COMMAND exits with non-zero status, skip test.
|
|
#
|
|
# 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
|
|
);
|
|
|
|
# MARK: - Public API
|
|
|
|
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} // '-p';
|
|
|
|
$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' },
|
|
pipefile => { type => 'string', once => 1 },
|
|
pipein => { type => 'string', once => 1 },
|
|
precheck => { type => 'string...' },
|
|
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->get_variable('srcdir', $opts);
|
|
$self->get_variable('top_builddir', $opts);
|
|
|
|
$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 add_variant {
|
|
my ($self, $name, $hooks) = @_;
|
|
|
|
if (!defined($self->{variants})) {
|
|
$self->{variants} = [];
|
|
$self->add_directive('variants' => { type => 'string...', once => 1 });
|
|
}
|
|
for my $variant (@{$self->{variants}}) {
|
|
if ($variant->{name} eq $name) {
|
|
$self->die("variant $name already defined");
|
|
}
|
|
}
|
|
|
|
push @{$self->{variants}}, { name => $name, hooks => $hooks };
|
|
|
|
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) = @_;
|
|
|
|
if (defined($self->{variants})) {
|
|
my @results = ();
|
|
$self->{original_test} = $self->{test};
|
|
|
|
my %variants;
|
|
|
|
if (defined($self->{test}->{variants})) {
|
|
%variants = map { $_ => 1; } @{$self->{test}->{variants}};
|
|
}
|
|
|
|
for my $variant (@{$self->{variants}}) {
|
|
next if (defined($self->{test}->{variants}) && !exists($variants{$variant->{name}}));
|
|
|
|
$self->{variant_hooks} = $variant->{hooks};
|
|
$self->{test} = dclone($self->{original_test});
|
|
$self->{variant} = $variant->{name};
|
|
$self->mangle_test_for_variant();
|
|
push @results, $self->runtest_one($variant->{name});
|
|
}
|
|
|
|
return @results;
|
|
}
|
|
else {
|
|
return $self->runtest_one();
|
|
}
|
|
}
|
|
|
|
|
|
sub runtest_one {
|
|
my ($self, $tag) = @_;
|
|
|
|
$ENV{TZ} = "UTC";
|
|
$ENV{LC_CTYPE} = "C";
|
|
$ENV{POSIXLY_CORRECT} = 1;
|
|
$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];
|
|
}
|
|
my $preload_env_var = 'LD_PRELOAD';
|
|
if ($^O eq 'darwin') {
|
|
$preload_env_var = 'DYLD_INSERT_LIBRARIES';
|
|
}
|
|
if (defined($self->{test}->{'preload'})) {
|
|
if (-f cwd() . "/../.libs/$self->{test}->{'preload'}") {
|
|
$ENV{$preload_env_var} = cwd() . "/../.libs/$self->{test}->{'preload'}";
|
|
} else {
|
|
$ENV{$preload_env_var} = cwd() . "/../lib$self->{test}->{'preload'}";
|
|
}
|
|
}
|
|
|
|
$self->run_program();
|
|
|
|
for my $env (@{$self->{test}->{'setenv'}}) {
|
|
delete ${ENV{$env->[0]}};
|
|
}
|
|
if (defined($self->{test}->{'preload'})) {
|
|
delete ${ENV{$preload_env_var}};
|
|
}
|
|
|
|
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) = @_;
|
|
|
|
my @save_argv = @ARGV;
|
|
@ARGV = @argv;
|
|
my $ok = GetOptions(
|
|
'help|h' => \my $help,
|
|
'keep-broken|k' => \$self->{keep_broken},
|
|
'no-cleanup' => \$self->{no_cleanup},
|
|
# 'run-gdb' => \$self->{run_gdb},
|
|
'setup-only' => \$self->{setup_only},
|
|
'verbose|v' => \$self->{verbose}
|
|
);
|
|
@argv = @ARGV;
|
|
@ARGV = @save_argv;
|
|
|
|
if (!$ok || scalar(@argv) != 1 || $help) {
|
|
print STDERR "Usage: $0 [-hv] [--keep-broken] [--no-cleanup] [--setup-only] 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});
|
|
$self->run_precheck() if ($self->{test}->{precheck});
|
|
|
|
$self->end_test('SKIP') if ($self->{test}->{preload} && $^O eq 'darwin');
|
|
}
|
|
|
|
|
|
# MARK: - 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) = @_;
|
|
|
|
my %features;
|
|
|
|
my $fh;
|
|
unless (open($fh, '<', "$self->{top_builddir}/config.h")) {
|
|
$self->die("cannot open config.h in top builddir $self->{top_builddir}");
|
|
}
|
|
while (my $line = <$fh>) {
|
|
if ($line =~ m/^#define HAVE_([A-Z0-9_a-z]*)/) {
|
|
$features{$1} = 1;
|
|
}
|
|
}
|
|
close($fh);
|
|
|
|
my @missing = ();
|
|
for my $feature (@{$self->{test}->{features}}) {
|
|
if (!$features{$feature}) {
|
|
push @missing, $feature;
|
|
}
|
|
}
|
|
|
|
if (scalar @missing > 0) {
|
|
my $reason = "missing features";
|
|
if (scalar(@missing) == 1) {
|
|
$reason = "missing feature";
|
|
}
|
|
$self->print_test_result('SKIP', "$reason: " . (join ' ', @missing));
|
|
$self->end_test('SKIP');
|
|
}
|
|
|
|
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";
|
|
|
|
diff_arrays($a, $b);
|
|
}
|
|
|
|
return $ok;
|
|
}
|
|
|
|
sub file_cmp($$) {
|
|
my ($a, $b) = @_;
|
|
my $result = 0;
|
|
open my $fha, "< $a";
|
|
open my $fhb, "< $b";
|
|
binmode $fha;
|
|
binmode $fhb;
|
|
BYTE: while (!eof $fha && !eof $fhb) {
|
|
if (getc $fha ne getc $fhb) {
|
|
$result = 1;
|
|
last BYTE;
|
|
}
|
|
}
|
|
$result = 1 if eof $fha != eof $fhb;
|
|
close $fha;
|
|
close $fhb;
|
|
return $result;
|
|
}
|
|
|
|
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 = file_cmp($real_expected, $got);
|
|
}
|
|
$ok = ($ret == 0);
|
|
}
|
|
|
|
return $ok;
|
|
}
|
|
|
|
sub list_files {
|
|
my ($root) = @_;
|
|
my $ls;
|
|
|
|
my @files = ();
|
|
my @dirs = ($root);
|
|
|
|
while (scalar(@dirs) > 0) {
|
|
my $dir = shift @dirs;
|
|
|
|
opendir($ls, $dir);
|
|
unless ($ls) {
|
|
# TODO: handle error
|
|
}
|
|
while (my $entry = readdir($ls)) {
|
|
my $file = "$dir/$entry";
|
|
if ($dir eq '.') {
|
|
$file = $entry;
|
|
}
|
|
|
|
if (-f $file) {
|
|
push @files, "$file";
|
|
}
|
|
if (-d $file && $entry ne '.' && $entry ne '..') {
|
|
push @dirs, "$file";
|
|
}
|
|
}
|
|
closedir($ls);
|
|
}
|
|
|
|
return @files;
|
|
}
|
|
|
|
sub compare_files() {
|
|
my ($self) = @_;
|
|
|
|
my $ok = 1;
|
|
|
|
|
|
my @files_got = sort(list_files("."));
|
|
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 get_variable {
|
|
my ($self, $name, $opts) = @_;
|
|
|
|
$self->{$name} = $opts->{$name} // $ENV{$name};
|
|
if (!defined($self->{$name}) || $self->{$name} eq '') {
|
|
my $fh;
|
|
unless (open($fh, '<', 'Makefile')) {
|
|
$self->die("cannot open Makefile: $!");
|
|
}
|
|
while (my $line = <$fh>) {
|
|
chomp $line;
|
|
if ($line =~ m/^$name = (.*)/) {
|
|
$self->{$name} = $1;
|
|
last;
|
|
}
|
|
}
|
|
close ($fh);
|
|
}
|
|
if (!defined($self->{$name} || $self->{$name} eq '')) {
|
|
$self->die("cannot get variable $name");
|
|
}
|
|
}
|
|
|
|
|
|
sub mangle_test_for_variant {
|
|
my ($self) = @_;
|
|
|
|
$self->{test}->{stdout} = $self->strip_tags($self->{variant}, $self->{test}->{stdout});
|
|
$self->{test}->{stderr} = $self->strip_tags($self->{variant}, $self->{test}->{stderr});
|
|
$self->run_hook('mangle_test');
|
|
|
|
return 1;
|
|
}
|
|
|
|
sub parse_args {
|
|
my ($self, $type, $str) = @_;
|
|
|
|
if ($type eq 'string...') {
|
|
my $args = [];
|
|
|
|
while ($str ne '') {
|
|
if ($str =~ m/^\"/) {
|
|
unless ($str =~ m/^\"([^\"]*)\"\s*(.*)/) {
|
|
$self->warn_file_line("unclosed quote in [$str]");
|
|
return undef;
|
|
}
|
|
push @$args, $1;
|
|
$str = $2;
|
|
}
|
|
else {
|
|
$str =~ m/^(\S+)\s*(.*)/;
|
|
push @$args, $1;
|
|
$str = $2;
|
|
}
|
|
}
|
|
|
|
return $args;
|
|
}
|
|
elsif ($type =~ m/(\s|\.\.\.$)/) {
|
|
my $ellipsis = 0;
|
|
if ($type =~ m/(.*)\.\.\.$/) {
|
|
$ellipsis = 1;
|
|
$type = $1;
|
|
}
|
|
my @types = split /\s+/, $type;
|
|
my @strs = split /\s+/, $str;
|
|
my $optional = 0;
|
|
for (my $i = scalar(@types) - 1; $i >= 0; $i--) {
|
|
last unless ($types[$i] =~ m/(.*)\?$/);
|
|
$types[$i] = $1;
|
|
$optional++;
|
|
}
|
|
|
|
if ($ellipsis && $optional > 0) {
|
|
# TODO: check this when registering a directive
|
|
$self->warn_file_line("can't use ellipsis together with optional arguments");
|
|
return undef;
|
|
}
|
|
if (!$ellipsis && (scalar(@strs) < scalar(@types) - $optional || scalar(@strs) > scalar(@types))) {
|
|
my $expected = scalar(@types);
|
|
if ($optional > 0) {
|
|
$expected = ($expected - $optional) . "-$expected";
|
|
}
|
|
$self->warn_file_line("expected $expected 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);
|
|
|
|
unless (defined($args)) {
|
|
$ok = 0;
|
|
next;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
if ($test{pipefile} && $test{pipein}) {
|
|
$self->warn_file("both pipefile and pipein set, choose one");
|
|
$ok = 0;
|
|
}
|
|
|
|
if (defined($self->{variants})) {
|
|
if (defined($test{variants})) {
|
|
for my $name (@{$test{variants}}) {
|
|
my $found = 0;
|
|
for my $variant (@{$self->{variants}}) {
|
|
if ($name eq $variant->{name}) {
|
|
$found = 1;
|
|
last;
|
|
}
|
|
}
|
|
if ($found == 0) {
|
|
$self->warn_file("unknown variant $name");
|
|
$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 ($self->{variant}) {
|
|
if (defined($self->{$proc}->{"$self->{variant}/$ext"})) {
|
|
for my $sub (@{$self->{$proc}->{"$self->{variant}/$ext"}}) {
|
|
my $ret = $sub->($self, $got, $expected);
|
|
return $ret if (defined($ret));
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
|
|
my @hooks = ();
|
|
|
|
if (defined($self->{variant_hooks}) && defined($self->{variant_hooks}->{$hook})) {
|
|
push @hooks, $self->{variant_hooks}->{$hook};
|
|
}
|
|
if (defined($self->{hooks}->{$hook})) {
|
|
push @hooks, @{$self->{hooks}->{$hook}};
|
|
}
|
|
|
|
for my $sub (@hooks) {
|
|
unless ($sub->($self, $hook, $self->{variant})) {
|
|
$self->warn("hook $hook failed");
|
|
$ok = 0;
|
|
}
|
|
}
|
|
|
|
return $ok;
|
|
}
|
|
sub args_decode {
|
|
|
|
|
|
my ($str, $srcdir) = @_;
|
|
|
|
if ($str =~ m/\\/) {
|
|
$str =~ s/\\a/\a/gi;
|
|
$str =~ s/\\b/\b/gi;
|
|
$str =~ s/\\f/\f/gi;
|
|
$str =~ s/\\n/\n/gi;
|
|
$str =~ s/\\r/\r/gi;
|
|
$str =~ s/\\t/\t/gi;
|
|
$str =~ s/\\v/\cK/gi;
|
|
$str =~ s/\\s/ /gi;
|
|
# TODO: \xhh, \ooo
|
|
$str =~ s/\\(.)/$1/g;
|
|
}
|
|
|
|
if ($srcdir !~ m,^/,) {
|
|
$srcdir = "../$srcdir";
|
|
}
|
|
|
|
if ($str =~ m/^\$srcdir(.*)/) {
|
|
$str = "$srcdir$1";
|
|
}
|
|
|
|
return $str;
|
|
}
|
|
|
|
|
|
sub run_precheck {
|
|
my ($self) = @_;
|
|
|
|
for my $precheck (@{$self->{test}->{precheck}}) {
|
|
unless (system(@{$precheck}) == 0) {
|
|
$self->print_test_result('SKIP', "precheck failed");
|
|
$self->end_test('SKIP');
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
|
|
sub run_program {
|
|
my ($self) = @_;
|
|
goto &pipein_win32 if $^O eq 'MSWin32' && $self->{test}->{pipein};
|
|
my ($stdin, $stdout, $stderr);
|
|
$stderr = gensym;
|
|
|
|
my @cmd = ('../' . $self->{test}->{program}, map ({ args_decode($_, $self->{srcdir}); } @{$self->{test}->{args}}));
|
|
|
|
### TODO: catch errors?
|
|
|
|
my $pid;
|
|
if ($self->{test}->{pipefile}) {
|
|
open(SPLAT, '<', $self->{test}->{pipefile});
|
|
my $is_marked = eof SPLAT; # mark used
|
|
$pid = open3("<&SPLAT", $stdout, $stderr, @cmd);
|
|
}
|
|
else {
|
|
$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>) {
|
|
if ($^O eq 'MSWin32') {
|
|
$line =~ s/[\r\n]+$//;
|
|
}
|
|
else {
|
|
chomp $line;
|
|
}
|
|
push @{$self->{stdout}}, $line;
|
|
}
|
|
my $prg = $self->{test}->{program};
|
|
$prg =~ s,.*/,,;
|
|
while (my $line = <$stderr>) {
|
|
if ($^O eq 'MSWin32') {
|
|
$line =~ s/[\r\n]+$//;
|
|
}
|
|
else {
|
|
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 pipein_win32() {
|
|
my ($self) = @_;
|
|
|
|
my $cmd = "$self->{test}->{pipein}| ..\\$self->{test}->{program} " . join(' ', map ({ args_decode($_, $self->{srcdir}); } @{$self->{test}->{args}}));
|
|
my ($success, $error_message, $full_buf, $stdout_buf, $stderr_buf) = IPC::Cmd::run(command => $cmd);
|
|
if (!$success) {
|
|
### TODO: catch errors?
|
|
}
|
|
|
|
my @stdout = map { s/[\r\n]+$// } @$stdout_buf;
|
|
$self->{stdout} = \@stdout;
|
|
$self->{stderr} = [];
|
|
|
|
my $prg = $self->{test}->{program};
|
|
$prg =~ s,.*/,,;
|
|
foreach my $line (@$stderr_buf) {
|
|
$line =~ s/[\r\n]+$//;
|
|
|
|
$line =~ s/^[^: ]*$prg: //;
|
|
if (defined($self->{test}->{'stderr-replace'})) {
|
|
$line = $self->stderr_rewrite($self->{test}->{'stderr-replace'}, $line);
|
|
}
|
|
push @{$self->{stderr}}, $line;
|
|
}
|
|
|
|
$self->{exit_status} = 1;
|
|
if ($success) {
|
|
$self->{exit_status} = 0;
|
|
}
|
|
elsif ($error_message =~ /exited with value ([0-9]+)$/) {
|
|
$self->{exit_status} = $1 + 0;
|
|
}
|
|
}
|
|
|
|
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("cannot 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;
|
|
remove_tree($self->{sandbox_dir});
|
|
|
|
return $ok;
|
|
}
|
|
|
|
|
|
sub strip_tags {
|
|
my ($self, $tag, $lines) = @_;
|
|
|
|
my @stripped = ();
|
|
|
|
for my $line (@$lines) {
|
|
if ($line =~ m/^<([a-zA-Z0-9_]*)> (.*)/) {
|
|
if ($1 eq $tag) {
|
|
push @stripped, $2;
|
|
}
|
|
}
|
|
else {
|
|
push @stripped, $line;
|
|
}
|
|
}
|
|
|
|
return \@stripped;
|
|
}
|
|
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
# MARK: array diff
|
|
|
|
sub diff_arrays {
|
|
my ($a, $b) = @_;
|
|
|
|
my ($i, $j);
|
|
for ($i = $j = 0; $i < scalar(@$a) || $j < scalar(@$b);) {
|
|
if ($i >= scalar(@$a)) {
|
|
print "+$b->[$j]\n";
|
|
$j++;
|
|
}
|
|
elsif ($j >= scalar(@$b)) {
|
|
print "-$a->[$i]\n";
|
|
$i++;
|
|
}
|
|
elsif ($a->[$i] eq $b->[$j]) {
|
|
print " $a->[$i]\n";
|
|
$i++;
|
|
$j++;
|
|
}
|
|
else {
|
|
my ($off_a, $off_b) = find_best_offsets($a, $i, $b, $j);
|
|
my ($off_b_2, $off_a_2) = find_best_offsets($b, $j, $a, $i);
|
|
|
|
if ($off_a + $off_b > $off_a_2 + $off_b_2) {
|
|
$off_a = $off_a_2;
|
|
$off_b = $off_b_2;
|
|
}
|
|
|
|
for (my $off = 0; $off < $off_a; $off++) {
|
|
print "-$a->[$i]\n";
|
|
$i++;
|
|
}
|
|
for (my $off = 0; $off < $off_b; $off++) {
|
|
print "+$b->[$j]\n";
|
|
$j++;
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
sub find_best_offsets {
|
|
my ($a, $i, $b, $j) = @_;
|
|
|
|
my ($best_a, $best_b);
|
|
|
|
for (my $off_a = 0; $off_a < (defined($best_a) ? $best_a + $best_b : scalar(@$a) - $i); $off_a++) {
|
|
my $off_b = find_entry($a->[$i+$off_a], $b, $j, defined($best_a) ? $best_a + $best_b - $off_a : scalar(@$b) - $j);
|
|
|
|
next unless (defined($off_b));
|
|
|
|
if (!defined($best_a) || $best_a + $best_b > $off_a + $off_b) {
|
|
$best_a = $off_a;
|
|
$best_b = $off_b;
|
|
}
|
|
}
|
|
|
|
if (!defined($best_a)) {
|
|
return (scalar(@$a) - $i, scalar(@$b) - $j);
|
|
}
|
|
|
|
return ($best_a, $best_b);
|
|
}
|
|
|
|
sub find_entry {
|
|
my ($entry, $array, $start, $max_offset) = @_;
|
|
|
|
for (my $offset = 0; $offset < $max_offset; $offset++) {
|
|
return $offset if ($array->[$start + $offset] eq $entry);
|
|
}
|
|
|
|
return undef;
|
|
}
|
|
|
|
1;
|