#! /usr/bin/env perl
use strict;
use warnings;
use IO::Select;
use IO::Handle;
use FileHandle;
use IPC::Open2;
use Getopt::Long;
use Pod::Usage;
use File::Basename;
use File::Temp "tempdir";
use File::Path qw(make_path remove_tree);
use List::Util qw(all);
use Scalar::Util qw(looks_like_number);
use Time::HiRes "time";

BEGIN { unshift @INC, dirname($0).'/../lib'; }

use L4::TapWrapper;

my $help = 0;
my $nowrapper = 0;
my $max_lines_read = $ENV{MAX_LINES_READ} || 100000;
my $test_timestamps = !!$ENV{TEST_TIMESTAMPS};
my $jdb_system_report_on_timeout = !!$ENV{TEST_JDB_SYSTEM_REPORT_ON_TIMEOUT};
my $enter_jdb_on_timeout = !!$ENV{TEST_ENTER_JDB_ON_TIMEOUT};
my $attach_string;

my $additional_test_args = "";

Getopt::Long::Configure ("bundling");
GetOptions(
  'help|h'  => \$help,
  'debug|d' => sub { $additional_test_args .= ' -vvv';
                     $ENV{TEST_KUNIT_VERBOSE} = 1;
                     $ENV{TEST_KUNIT_DEBUG} = 1;

                     # Ensure serial_esc present in Kernel args, but don't overwrite others
                     $ENV{KERNEL_ARGS} //= '';
                     $ENV{KERNEL_ARGS} .= ' -serial_esc' if $ENV{KERNEL_ARGS} !~ /-serial_esc/;
                     $nowrapper = 1;
                   },
  'verbose|v'       => sub { $additional_test_args .= ' -v';
                             $ENV{TEST_KUNIT_VERBOSE} = 1 },
  'record|r'        => sub { $additional_test_args .= ' -r';
                             $ENV{TEST_KUNIT_RECORD} = 1;
                           },
  'only|o=s'        => sub { $additional_test_args .= " --gtest_filter=*$_[1]"; },
  'shuffle:i'       => \&gtest_shuffle_seed,
  'no-wrapper|W'    => \$nowrapper,
  'fiasco-args|f=s' => sub { $ENV{KERNEL_ARGS} = $_[1]; },
  'moe-args|m=s'    => sub { $ENV{MOE_ARGS} = $_[1]; },
  'boot-args|b=s'   => sub { $ENV{BOOTSTRAP_ARGS} = $_[1]; },
  'test-args|t=s'   => sub { $ENV{TEST_ARGS} = $_[1]; },
  'run-tags|T=s'    => sub { $ENV{TEST_RUN_TAGS} = $_[1]; },
  'logfile|l=s'     => sub { $ENV{TEST_LOGFILE} = $_[1]; },
  'plugin|p=s'      => sub { $ENV{TEST_TAP_PLUGINS} .= " $_[1]"; },
  'filter|F=s'      => sub { $ENV{TAP_FILTERS} .= " $_[1]"; },
  'workdir=s'       => sub { $ENV{TEST_WORKDIR} = $_[1]; },
  'image|i=s'       => sub { $ENV{IMAGE_TYPE} = $_[1]; },
  'hard-timeout=i'  => sub { $ENV{TEST_HARD_TIMEOUT} = $_[1]; },
  'max-lines-read=i'=> sub { $max_lines_read = 0+ $_[1]; },
  'timestamps'      => sub { $test_timestamps = 1; },
  'jdb-system-report-on-timeout' => \$jdb_system_report_on_timeout,
  'enter-jdb-on-timeout' => \$enter_jdb_on_timeout,
  'attach' => \$attach_string,
  'break-on-failure' => sub { $additional_test_args .= " --gtest_break_on_failure";
                              $attach_string = "ATKINS_ASSERT_KDEBUG";
                            },
  'introspection-tests' => sub { $ENV{INTROSPECTION_TESTS} = 1; },
  'exclude-filters=s' => sub { $ENV{TEST_EXCLUDE_FILTERS} .= " $_[1]"; }
) or pod2usage(-verbose => 99, -sections => "OPTIONS");

$ENV{TEST_ARGS} .= $additional_test_args;

pod2usage(-verbose => 99,
          -sections => "SYNOPSIS|OPTIONS|DESCRIPTION|HARDWARE CONFIGURATION"
         ) if $help;

if ($nowrapper)
  {
    print("WARNING: output capturing disabled, the test will not terminate automatically.\n");
    system(@ARGV);
    exit($? == 69 ? 0 : $?);
  }

if ($jdb_system_report_on_timeout || $enter_jdb_on_timeout)
  {
    # Ensure serial_esc present in Kernel args, but don't overwrite others
    $ENV{KERNEL_ARGS} //= "";
    $ENV{KERNEL_ARGS} .= ' -serial_esc' if $ENV{KERNEL_ARGS} !~ /-serial_esc/;
  }

my $timeout = $ENV{TEST_TIMEOUT};
$timeout = 10 unless defined $timeout && looks_like_number($timeout);
$L4::TapWrapper::timeout = $timeout;
my $testfile = $ENV{TEST_TESTFILE};
my $target = $ENV{TEST_TARGET};
my $expline;
$L4::TapWrapper::test_description = $ENV{TEST_DESCRIPTION} || ($testfile ? "run $testfile" : '') || join(" ", map { $_ ? basename($_) : () } @ARGV, $target);

$L4::TapWrapper::harness_active = !!$ENV{HARNESS_ACTIVE};

my $workdir = $ENV{TEST_WORKDIR} || tempdir("/tmp/tap-wrapper-workdir-XXXXXXXX", CLEANUP => 1);
my ($name, $path, $suffix) = fileparse($testfile || $target || "", ".t");
my $testworkdir   = "$workdir/$path$name";
my $logdir        = "$testworkdir/log"; # tap-wrapper logs
my $rundir        = "$testworkdir/run"; # run_test modules.list, ned files, etc.
$L4::TapWrapper::logdir = $logdir;
$ENV{TEST_TMPDIR} = $rundir;
make_path($logdir, $rundir);
$L4::TapWrapper::plugintmpdir = "$ENV{TEST_PLUGIN_TMPDIR}/$path$name" if $ENV{TEST_PLUGIN_TMPDIR};
$L4::TapWrapper::plugintmpdir ||= "$testworkdir/plugins";

END {
  # On demand: delete plugin dir on exit
  remove_tree($L4::TapWrapper::plugintmpdir)
    if $ENV{TEST_PLUGIN_TMPDIR_CLEANUP};
}

my $logfile       = $ENV{TEST_LOGFILE} || "$logdir/testout.log";
open(my $LOG_FD, ">>", $logfile) or die "Can not open logfile '$logfile'\n";

sub get_timestamp {
  return sprintf("[%8.3fs] ", (time() - $^T));
}

sub gtest_shuffle_seed
{
  $ENV{TEST_GTEST_SHUFFLE} = 1;
  $ENV{TEST_GTEST_SHUFFLE_SEED} = $_[1];
}

if ($ENV{TAPPER_OUTPUT})
  {
    open($L4::TapWrapper::TAP_FD, $ENV{TAPPER_OUTPUT}) or die "Cannot open tapper output\n";
  }
else
  {
    open($L4::TapWrapper::TAP_FD, '>&', STDOUT);
    if ($L4::TapWrapper::harness_active) # Running under prove
      {
        # Redirect all printing to the logfile
        open(STDOUT, '>', '/dev/null');
        open(STDERR, '>&', $LOG_FD);

        # What evil lies behind STDIN is not human. (°_°')
        # So let's bury STDIN and open something else instead.
        open(STDIN, '<', '/dev/null');
      }
    else
      {
        open(STDOUT, '>&', STDERR);
        $L4::TapWrapper::print_to_tap_fd = 0;
      }
  }

$L4::TapWrapper::TAP_FD->autoflush(1);

my %filter_excludes;
if ($ENV{TEST_EXCLUDE_FILTERS})
  {
    $filter_excludes{$_} = 1 foreach split(' ', $ENV{TAP_FILTERS});
  }

if ($ENV{TAP_FILTERS})
  {
    foreach (split(' ', $ENV{TAP_FILTERS}))
      {
        my ($name, $arg) = split(/:/, $_, 2);
        $arg = "" unless defined $arg;
        my %harg = map { split(/=/, $_, 2) } (split (/,/, $arg));
        L4::TapWrapper::load_filter($name, \%harg)
          unless $filter_excludes{$name};
      }
  }

if ($ENV{INTROSPECTION_TESTS})
  {
    L4::TapWrapper::load_plugin("External", {
      tag => "IntrospectionTesting;KernelObjects",
      tool => "tool/bin/tap-wrapper-plugins/introspection-testing.lua"
    });
  }

if ($ENV{TEST_TAP_PLUGINS})
  {
    foreach ($ENV{TEST_TAP_PLUGINS} =~ m/\S+/g)
      {
        my ($name, $args) = L4::TapWrapper::parse_plugin($_);
        L4::TapWrapper::load_plugin($name, $args);
      }
  }

L4::TapWrapper::fail_test("No tap-wrapper plugins loaded. Test does not provide TEST_TAP_PLUGINS correctly.")
  unless L4::TapWrapper::has_plugins_loaded();


my $hard_timeout = 0+ ($ENV{TEST_HARD_TIMEOUT} || 0);
if ($hard_timeout)
  {
    $SIG{ALRM} = sub {
      on_test_abort("Test did not finish within the hard timeout of $hard_timeout seconds");
    };
    alarm $hard_timeout;
  }

print $L4::TapWrapper::TAP_FD "TAP Version 13\n";
my $repeat = $ENV{TEST_EXPECTED_REPEAT};
if (looks_like_number($repeat) and $repeat == 0 and $L4::TapWrapper::harness_active)
  {
    print $L4::TapWrapper::TAP_FD "1..0 # SKIP ";
    print $L4::TapWrapper::TAP_FD "infinite test cannot be run when harness is active\n";
    L4::TapWrapper::exit_test(69);
  }

my $test_proc;
my $test_proc_input;

# Disable shuffling if we have a plugin that does not support it
if ($ENV{TEST_GTEST_SHUFFLE} && not all { $_ } L4::TapWrapper::plugin_features('shuffling_support'))
  {
     print "Warning: Not all plugins support test shuffling. Ignoring gtest shuffle.\n";
     $ENV{TEST_GTEST_SHUFFLE} = 0;
  }

if ($jdb_system_report_on_timeout || $enter_jdb_on_timeout)
  {
    # If we're supposed to dump the system report we need
    # a dedicated pipe to the child processes stdin...
    $L4::TapWrapper::pid = open2($test_proc, $test_proc_input, @ARGV)
      or die "Failed to execute $ARGV[0].\n";
  }
else
  {
    # ... otherwise pass our stdin to the child process.
    $L4::TapWrapper::pid = open2($test_proc, '<&STDIN', @ARGV)
      or die "Failed to execute $ARGV[0].\n";
    # The previous command closes stdin (fd 0). This leads to warnings when
    # opening other files for writing, as open will reuse fd 0 and warn that
    # STDIN is now write-only. To prevent these warnings we open /dev/null
    # read-only as STDIN so that open will not use it.
    open(STDIN, '<', '/dev/null');
  }

my $readbuffer = '';
my $lines_read = 0;

my $buffer_is_at_newline = 1;

sub trigger_jdb_system_report {
  # Disable any running ALRM signal
  alarm 0;
  # Disable line limit for this
  $max_lines_read = 0;

  # Wait for at most 5 seconds for JDB prompt.
  # After that we can safely assume there's no jdb.
  my $jdb_wait_timeout = time() + 5;

  # Trigger Jdb by sending ASCII code for Escape (33 oct = 27 dec)
  print $test_proc_input "\033";
  select(undef,undef,undef,0.2); # sleep(0.2) without Time::HiRes
  # Since readline_timeout only returns completed lines we have to send
  # an empty command so jdb shows a second prompt and we can read the first one.
  print $test_proc_input "\n";

  my $line;
  # Wait for prompt
  while($line = readline_timeout($test_proc, $jdb_wait_timeout - time()))
    {
      last if $line =~ /jdb:/;
    }

  # abort here if no jdb found
  unless ($line)
    {
      my $timestamp = "";
      $timestamp = get_timestamp if $test_timestamps;

      print $LOG_FD "$timestamp Warning: jdb did not react to trigger. Maybe missing?\n";
      print STDERR "$timestamp Warning: jdb did not react to trigger. Maybe missing?\n";
      return;
    }

  # Trigger system report
  print $test_proc_input "Y\n";

  # Wait for "Done" with 5 seconds as between-lines-timeout in case jdb itself misbehaves.
  while($line = readline_timeout($test_proc, 5))
    {
      last if $line =~ /^Done/;
    }
}

sub on_test_abort {
  my $reason = shift;

  trigger_jdb_system_report() if $jdb_system_report_on_timeout;

  if ($enter_jdb_on_timeout) {
    print $test_proc_input "\033";
    print "TIMEOUT($reason)\n";

    L4::TapWrapper::Util::do_interactive($test_proc, $test_proc_input);
  }

  L4::TapWrapper::fail_test($reason);
}

# Returns read line or undef in case of a timeout
sub readline_timeout
{
  my $handle = shift;
  my $timeout = shift;
  $timeout = undef if $timeout == 0;

  my $idx = index($readbuffer, "\n");
  if ($idx < 0)
    {
      my $sel = IO::Select->new();
      $sel->add($handle);

      my $was_blocking = $handle->blocking(0);
      while ($idx < 0 and $sel->can_read($timeout))
        {
          # If the following sysread fails, we must be at the end of the file.
          # Just return the content of the readbuffer in that case.
          $idx = length($readbuffer);
          while (sysread($handle, my $part, 128))
            {
              my $log_part = $part;
              if ($test_timestamps)
                {
                  my $timestamp = get_timestamp();
                  $log_part =~ s/\n(.)/\n$timestamp$1/gm;
                  if ($buffer_is_at_newline)
                    {
                      $log_part = $timestamp . $log_part;
                      $buffer_is_at_newline = 0;
                    }

                  if (substr($log_part,-1) eq "\n")
                    {
                      $buffer_is_at_newline = 1;
                    }
                }
              print $log_part;
              print $LOG_FD $log_part if $LOG_FD;
              $readbuffer .= $part;
              $idx = index($readbuffer, "\n");
              last if $idx >= 0;
            }
          STDOUT->flush();
        }
      $handle->blocking($was_blocking);

      # Timeout
      return undef if $idx < 0;
    }

  my $outline = '';
  $outline = substr($readbuffer, 0, $idx + 1, '') if $readbuffer;
  $lines_read++;

  if ($max_lines_read != 0 && $lines_read >= $max_lines_read) {
    # trigger_jdb_system_report increases lines_read more, but we only want to
    # display the lines received from the test later.
    my $test_lines_read = $lines_read;

    on_test_abort("Stopping test prematurely after $test_lines_read lines read.")
  }

  return $outline;
}


my $test_proc_exit_code = 0;


# look for TAP output
#$expline = "begin of TAP test output (TAP TEST START)";
my $test_eof = 0;
my $test_lastline = "";
my $test_current_read;

while ($test_current_read = readline_timeout($test_proc, $L4::TapWrapper::timeout))
  {
    $test_lastline = $test_current_read;

    if (defined($attach_string) && $test_current_read =~ qr/\Q$attach_string\E/)
      {
        L4::TapWrapper::Util::do_interactive($test_proc, $test_proc_input);
        last;
      }

    $test_eof = L4::TapWrapper::process_input($test_lastline);

    if ($test_eof)
      {
        my $wfm = L4::TapWrapper::calculate_wait_for_more();
        last unless $wfm > 0;
        $L4::TapWrapper::timeout = $wfm;
      }
    else
      {
        $L4::TapWrapper::timeout = $timeout;
      }
  }

if (!$test_eof)
  {
    if (!defined($test_current_read))
      {
        my $exp = $L4::TapWrapper::expline;
        my $msg = "Test timed out after $timeout seconds.";
        $msg .= defined($exp) ? " Was expecting: $exp" : "";

        on_test_abort($msg)
      }

    waitpid $L4::TapWrapper::pid, 0;
    $L4::TapWrapper::pid = -1;
    $test_proc_exit_code = $? >> 8;
    if ($test_proc_exit_code == 69)
      {
        print $L4::TapWrapper::TAP_FD "1..0 # SKIP $test_lastline\n";
        L4::TapWrapper::exit_test($test_proc_exit_code);
      }
    else
      {
        L4::TapWrapper::fail_test("Test program finished prematurely", $test_proc_exit_code)
      }
  }

L4::TapWrapper::finalize();
L4::TapWrapper::exit_test($test_proc_exit_code);

__END__

=head1 NAME

tap-wrapper - Wrapper for TAP test runner.

=head1 SYNOPSIS

tap-wrapper [options] [test-runner]

$TEST_SCRIPT [options]

=head1 OPTIONS

  --help,-h        Print this help message.
  --no-wrapper,-W  Disable output capturing and timeout check.
                   Necessary when using Fiasco JDB for debugging.

  --debug,-d       Run in debug mode. Shortcut for:
                     -W -vvv -f "-serial_esc"

  --fiasco-args,-f Additional arguments to pass to fiasco.
    <STRING>
  --moe-args,-m    Additional arguments to pass to Moe.
    <STRING>
  --test-args,-t   Arguments to pass to the test application.
    <STRING>

  --image,-i       Create an image of the given type instead of executing
    <STRING>       the test on the target platform, such as 'elfimage'.
                   For available image types see 'make help' in the
                   root of the build directory.

  --logfile,-l     Append output of test execution to the given file
    <STRING>       unless --workdir is given.

  --workdir        Create logs, temp and other files below the given
    <STRING>       directory. That directory is taken as base dir for
                   more automatically created subdir levels using the
                   current test path, in order to guarantee
                   conflict-free usage when running many different
                   tests with a common workdir. When --workdir is
                   provided then --logfile is ignored as it is
                   organized below workdir.

  --plugin,-p      Add a plugin for processing the output of the test.
                   Valid plugins can be found in lib/L4/TapWrapper/Plugin/.
                   An optional plugin specific argument can be appended to the
                   plugin name, separated by a colon. The TAPOutput and
                   BundleMode plugins are automatically loaded for backwards
                   compatibility. This option may be specified multiple times.

  --filter,-F      Add a filter for transforming the output of the test.
                   Valid filters can be found in lib/L4/TapWrapper/Filter/.
                   An optional filter specific argument can be appended to the
                   filter name, separated by a colon. This option may be
                   specified multiple times and filters are run in the order
                   specified with output of earlier filters being fed into later
                   ones.

  --timestamps     Prefix output sent to logfiles with time in seconds
                   since test was started. Alternatively the environment
                   variable TEST_TIMESTAMPS can be set 1.

  --jdb-system-report-on-timeout
                   When a test runs into a timeout, tap-wrapper shall try to
                   interact with JDB to dump its system report.

  --enter-jdb-on-timeout
                   Instead of terminating a test on timeout, try to open JDB.

  --attach <str>
                   When str is found in one line of output, attach user to input
                   stream.

  --break-on-failure
                   Tell atkins to enter JDB on test fail and let tap-wrapper switch
                   to interactive mode when it does.

                   Implies --attach "ATKINS_ASSERT_KDEBUG".

 Options for gtest test applications:
  --verbose,-v     Run test in verbose mode. Repeat for increased verbosity.
  --only,-o <TEST> Only run given test.
                   Produces command line: --gtest_filter=*<TEST>

  --shuffle        Run tests in random order. <SEED> is optional, if <SEED> is
    <SEED>         not provided this script generates one.
                   Produces gtest command line additions:
                     --gtest_shuffle --gtest_random_seed=<SEED>
                   For tests that run in 'expected output' mode those arguments
                   are not added.
                   Specifing gtest_shuffle in --test-args is undefined behavior.

  --record,-r      Record per test meta information such as path of the test
                   file, the line number of the test, and the name of the test.

  For more options to gtest try running with: --test-args -h

 Options for kunit test applications:
 --verbose, -v     Run test in verbose mode.
 --debug, -d       Enter the kernel debugger, if the test fails. Short for:
                     -W -v -f "-serial_esc"
 --record, -r      Record per test meta information such as the UUID.

=head1 DESCRIPTION

Filters output from a test runner and formats it to proper TAP.

The wrapper can run in two different modes:

1. If a file with expected output is given, then it will read this file,
   capture the output from the test runner and check for the given output.
   Each line in the expected output file is considered a regular expression
   that must match the beginning of a line.

2. If no expected output is given, the test runner is assumed to produce
   TAP output. The output must appear on stdout. It must start with
   'TAP TEST START' on a single line. Any output before this tag is ignored.
   Once the TAP output is finished, 'TAP TEST FINISH' must be printed
   after which the test runner will be killed immediately.

=head1 HARDWARE CONFIGURATION

If tests need hints about the actually available hardware you can set
an environment variable TEST_HWCONFIG to point to a hardware
configuration file which contains a collection of key=value pairs like
this:

  VIRTUALIZATION = 'y'
  AHCI = 'n'
  NUM_CPUS = 8

Those entries are provided in the 'test_env.lua' script under the key
t.HWCONFIG to be used like this:

 local t = require("rom/test_env");
 if (t.HWCONFIG.VIRTUALIZATION == 'y') then ... end

=head1 PLUGINS

Test output may be post-processed by so called plugins. They receive the output
of the tests and may emit additional TAP lines based on that output. One example
is BundleMode which aggregates multiple TAP blocks into a single one. The
default TAPOutput plugin parses a single TAP block. All plugins registered are
called in the order they are registered in for each output line. Plugins may
"steal" other plugins from the wrapper to interpose between the wrapper and the
output received by the other plugins.

Plugins may have additional arguments. When specified on the commandline the
syntax is as follows:

  --plugin PluginName:arg1=value1,arg2=value2,...

Plugins on the commandline are loaded in addition to the plugins specified by
the test.

Please see lib/L4/TapWrapper/Plugin.pm for details on the low level interface
for implementing plugins.

For compatibility currently C<BundleMode> and C<TAPOutput> are loaded per
default, unless an expected output file is provided (C<TEST_EXPECTED>) in which
case the C<OutputMatching> plugin is used.

=cut
