Revision control

++ #!/usr/bin/env perl
++ # Copyright (C) 2017 The Qt Company Ltd.
++ # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
++
++ use 5.010;
++ use strict;
++ use warnings;
++
++ package QtQA::App::TestPlanner;
++
++ =head1 NAME
++
++ testplanner - construct a test plan for a set of testcases
++
++ =head1 SYNOPSIS
++
++ # Make a plan to run all available tests under this directory ...
++ $ testplanner --input path/to/tests --output testplan.txt
++
++ # Then run them all
++ $ testscheduler --timeout 120 -j4 --sync-output --plan testplan.txt
++
++ testplanner will iterate through a build tree, collecting information
++ about autotests and preparing a test plan to be used by testrunner.
++
++ =head2 OPTIONS
++
++ =over
++
++ =item B<--input> PATH (mandatory)
++
++ Specifies the build tree from which a testplan should be created.
++
++ =item B<--output> PATH (mandatory)
++
++ Specifies the output test plan filename.
++
++ =item B<--make> MAKE
++
++ Customize the make command to be used for `make check'.
++ Defaults to `nmake' on Windows and `make' everywhere else.
++
++ =back
++
++ Further options may be passed to the testcases themselves.
++ These should be separated from testplanner options with a '--'.
++ For example:
++
++ testplanner --input . --output plan.txt -- -silent -no-crash-handler
++
++ ... to create a testplan which will run the tests with
++ "-silent -no-crash-handler" arguments.
++
++ =head1 DESCRIPTION
++
++ testplanner creates a testplan according to the contents of a given
++ build tree.
++
++ testplanner is primarily designed to work with qmake.
++
++ Any test which would be run by the `make check' command under the
++ build tree will be included in the test plan. This is normally
++ achieved by using CONFIG+=testcase in a testcase .pro file.
++ Custom `check' targets may also be used, but these B<must> support
++ the $(TESTRUNNER) parameter to `make check' as CONFIG+=testcase does.
++
++ The precise output format of testplanner is undefined, but it
++ is plaintext and may be influenced by values from the buildsystem
++ such as:
++
++ =over
++
++ =item CONFIG+=insignificant_test
++
++ Indicates the result of the test can be ignored.
++
++ =item CONFIG+=parallel_test
++
++ Indicates the test is safe to run in parallel with other tests.
++
++ =item testcase.timeout=I<timeout>
++
++ The maximum permitted runtime of the test, in seconds.
++
++ =back
++
++
++
++ =cut
++
++ use Data::Dumper;
++ use English qw(-no_match_vars);
++ use Fcntl qw(LOCK_EX LOCK_UN SEEK_END);
++ use File::Basename;
++ use File::Spec::Functions qw(:ALL);
++ use File::chdir;
++ use Getopt::Long;
++ use IO::File;
++ use Lingua::EN::Inflect qw(inflect);
++ use List::MoreUtils qw(any apply all pairwise each_arrayref);
++ use Pod::Usage;
++ use QMake::Project;
++ use Readonly;
++ use Scalar::Defer qw(force);
++
++ use FindBin;
++ use lib "$FindBin::Bin/../lib/perl5";
++
++ use autodie;
++
++ Readonly my $WINDOWS => ($OSNAME =~ m{win32}i);
++
++ sub new
++ {
++ my ($class) = @_;
++ return bless {
++ this_script => rel2abs( $0 ),
++ }, $class;
++ }
++
++ sub run
++ {
++ my ($self, @args) = @_;
++
++ my $testcase;
++
++ local @ARGV = @args;
++ GetOptions(
++ 'help|?' => sub { pod2usage(0) },
++ 'input=s' => \$self->{ input },
++ 'output=s' => \$self->{ output },
++ 'make=s' => \$self->{ make },
++ 'makefile=s' => \$self->{ makefile },
++ 'testcase' => \$testcase,
++ ) || pod2usage(2);
++
++ # Testcase mode; we're calling ourselves for one specific testcase.
++ # The remaining args are the testcase command and arguments.
++ if ($testcase) {
++ return $self->plan_testcase( @ARGV );
++ }
++
++ foreach my $arg (qw(input output)) {
++ $self->{ $arg } || die "Missing mandatory --$arg argument";
++ }
++
++ # We can't safely handle arguments with spaces.
++ # The processing of TESTARGS within the makefile depends on the exact
++ # shell being used, which is generally quite difficult to determine
++ # (e.g. mingw32-make uses sh.exe if it is in PATH, cmd.exe otherwise).
++ # It's not impossible to support this, but we won't bother until
++ # it becomes necessary.
++ if (any { m{ } } @ARGV) {
++ die 'sorry, it is currently not supported to pass arguments with '
++ ."spaces while generating a test plan.\nYour arguments were:\n"
++ .(join(' ', map { "[$_]" } @ARGV));
++ }
++
++ # We're going to pass output to subprocesses with a different
++ # working directory, we'd better make it absolute
++ $self->{ output } = rel2abs( $self->{ output } );
++
++ # And also delete it if it currently exists
++ if (-e $self->{ output }) {
++ unlink( $self->{ output } );
++ }
++
++ if (!$self->{ make }) {
++ $self->{ make } = $self->default_make( );
++ }
++
++ $self->run_make_check( @ARGV );
++ $self->finalize_test_plan( $self->{ output } );
++
++ return;
++ }
++
++ # finalize the test plan;
++ # this may modify the test plan slightly (e.g. changing some labels to ensure
++ # there are no duplicate testcase names).
++ sub finalize_test_plan
++ {
++ my ($self, $filename) = @_;
++
++ if (! -e $filename) {
++ warn "No tests found under $self->{ input }\n";
++
++ # No tests? make an empty testplan.
++ open( my $fh, '>', $filename ) || die "open $filename for create: $!";
++ close( $fh ) || die "close $filename after create: $!";
++
++ return;
++ }
++
++ my @tests;
++ my $count = 0;
++ my $fh = IO::File->new( $filename, '<' ) || die "open $filename: $!";
++ while (my $line = <$fh>) {
++ ++$count;
++ my $test = eval $line; ## no critic (ProhibitStringyEval) - no way around it
++ if (my $error = $@) {
++ die "$filename:$count: error: $error";
++ }
++ push @tests, $test;
++ }
++
++ if ($self->ensure_distinct_labels( \@tests )) {
++ # modified - have to write it back out again.
++ open( my $fh, '>', $filename ) || die "open $filename for truncate: $!";
++ close( $fh ) || die "close $filename after truncate: $!";
++ $self->write_testcase( @tests );
++ }
++
++ print inflect "Test plan generated for NO(test,$count) at $filename\n";
++
++ return;
++ }
++
++ # Ensures that all tests referred to by $all_tests_ref (arrayref) have a unique
++ # label. Returns 1 if the labels had to be modified in order to achieve this.
++ #
++ # Currently the labels may be modified by finding the first unique word in a
++ # test's CWD and command combined. For example, for these two tests:
++ #
++ # /build/qtdeclarative/tests/auto/tst_examples/tst_examples
++ # /build/qtquick1/tests/auto/tst_examples/tst_examples
++ #
++ # Their default label would be "tst_examples"; this function would amend them
++ # to "tst_examples (qtdeclarative)" and "tst_examples (qtquick1)".
++ #
++ sub ensure_distinct_labels
++ {
++ my ($self, $all_tests_ref) = @_;
++
++ my $modified = 0;
++
++ # Build a map from each label to a list of tests with that label
++ my %tests_by_label;
++ foreach my $test (@{ $all_tests_ref }) {
++ my $label = $test->{ label };
++ push @{ $tests_by_label{ $label } }, $test;
++ }
++
++ while (my ($label, $tests_ref) = each %tests_by_label) {
++ my @tests = @{ $tests_ref };
++ next unless @tests > 1; # nothing to be done if already unique ...
++
++ # found something not unique, we'll have to modify it.
++ $modified = 1;
++
++ # For each test, make a string containing that test's CWD and command/args.
++ # There must be some difference in this value between the tests (otherwise
++ # it is the same test!)
++ #
++ # Example:
++ # "/build/qtdeclarative/tests/auto/tst_examples ./tst_examples"
++ # "/build/qtquick1/tests/auto/tst_examples ./tst_examples"
++ #
++ my @cwd_and_args = map { join(' ', $_->{ cwd }, @{ $_->{ args }}) } @tests;
++
++ # Find the first unique word from each CWD-and-args string.
++ #
++ # Example:
++ # ("qtdeclarative", "qtquick1")
++ #
++ my @words = $self->find_first_unique_word( @cwd_and_args );
++
++ # append the unique words to the label.
++ pairwise {
++ # this line avoids "used only once: possible typo" warnings
++ our ($a, $b);
++ if ($b) {
++ $a->{ label } .= " ($b)"
++ }
++ } @tests, @words;
++ }
++
++ return $modified;
++ }
++
++ # Given a list of @input strings, returns a list (of the same size) of output words.
++ # Each word is the first unique word from each string (where "word" is defined in
++ # the perl regular expression sense).
++ sub find_first_unique_word
++ {
++ my ($self, @input) = @_;
++
++ # Split on word boundaries, and also consume the non-word characters (e.g.
++ # directory separators).
++ # Conceptually, this gives us a two-dimensional array where the rows are input
++ # strings and columns are individual words in that string.
++ my @input_words = map { [
++ split( /\W*\b\W*/, $_ )
++ ] } @input;
++
++ # Prepare output, initialized as a list of empty strings, already at the right size.
++ my @output = (q{}) x (scalar(@input));
++
++ # Iterate over each column in the array ...
++ my $ea = each_arrayref @input_words;
++ while (my @words = $ea->()) {
++
++ # for each output not yet set ...
++ for (my $i = 0; $i < @output; ++$i) {
++ next if ($output[$i]);
++
++ # if the word at this column is unique, set it as output.
++ my $word = $words[$i];
++ next unless $word;
++ my $count = scalar( grep { $_ eq $word } @words );
++ if ($count == 1) {
++ $output[$i] = $word;
++ }
++ }
++
++ # terminate if all output has been set.
++ last if all { $_ } @output;
++ }
++
++ return @output;
++ }
++
++ sub default_make
++ {
++ my ($self) = @_;
++
++ if ($WINDOWS) {
++ return 'nmake';
++ }
++
++ return 'make';
++ }
++
++ # Returns 'GNU', 'MS' or 'unknown' depending on the type of make
++ sub make_flavor
++ {
++ my ($self) = @_;
++
++ my $make = $self->{ make };
++
++ if ($make =~ m{\bjom|\bnmake}i) {
++ return 'MS';
++ }
++ if ($make =~ m{\bgmake|\bmake|\bmingw32-make}) {
++ return 'GNU';
++ }
++ return 'unknown';
++ }
++
++ # Returns text usable within make to evaluate the current makefile.
++ sub makefile_var
++ {
++ my ($self) = @_;
++
++ if ($self->make_flavor( ) eq 'MS') {
++ # FIXME: how to accurately figure out the calling Makefile on Windows?
++ # We know $(MAKEDIR) points to the right directory, but the actual
++ # filename appears not exposed in any way.
++ #
++ # Since there's no way to accurately determine it, we instead glob for
++ # all "Makefile*", and decide ourselves which one is the right one
++ # (e.g. discounting Makefile.Release and Makefile.Debug).
++ #
++ # Note that for nmake specifically, and not jom, it is necessary to
++ # double-escape the variable ($$), otherwise it is evaluated too early.
++ # It's not entirely clear why this is necessary for nmake and not for
++ # other tools; the method which nmake uses to pass "TESTRUNNER" etc
++ # args to submakes appears to be undocumented.
++ my $out = '$(MAKEDIR)\Makefile*';
++ if ($self->{ make } =~ m{\bnmake}i) {
++ $out = '$'.$out;
++ }
++ return $out;
++ }
++
++ # $(CURDIR): initial working directory of make.
++ # $(firstword $(MAKEFILE_LIST)): first processed Makefile.
++ return '$(CURDIR)/$(firstword $(MAKEFILE_LIST))';
++ }
++
++ sub resolved_makefile
++ {
++ my ($self) = @_;
++
++ my $makefile = $self->{ makefile } || 'Makefile';
++
++ # no globbing necessary on makefile flavors other than MS.
++ if ($self->make_flavor() ne 'MS') {
++ return $makefile;
++ }
++
++ my @globbed = glob $makefile;
++
++ # Omit .Debug and .Release makefiles. There should be a top-level makefile.
++ @globbed = grep { $_ !~ m{\. (?:Debug|Release) \z}xms } @globbed;
++
++ if (!@globbed) {
++ die "In $CWD, no makefile found (looking for: $makefile)\n";
++ }
++
++ # If we found only one makefile, great! That's the one.
++ # This is the expected case, the vast majority of the time.
++ if (@globbed == 1) {
++ return $globbed[0];
++ }
++
++ # Otherwise, call out to our helper script which can figure out the calling
++ # makefile from the process table.
++ my $calling_makefile = qx("$EXECUTABLE_NAME" "$FindBin::Bin/print_calling_makefile.pl");
++ my $status = $?;
++ chomp $calling_makefile;
++
++ # Worst case scenario - we can't figure out the makefile at all.
++ # Give up.
++ if (!$calling_makefile || $status) {
++ die "Error: ambiguous makefiles:\n"
++ .join( q{}, map { " $_\n" } @globbed );
++ }
++
++ # $calling_makefile would most likely be a relative path, make it absolute.
++ # It is resolved relative to whatever directory was used in the glob pattern.
++ if (!file_name_is_absolute( $calling_makefile )) {
++ $calling_makefile = rel2abs( $calling_makefile, dirname( $makefile ) );
++ }
++
++ return $calling_makefile;
++ }
++
++ sub plan_testcase
++ {
++ my ($self, $testcase, @args) = @_;
++
++ my $make = $self->{ make };
++ my $makefile = $self->resolved_makefile( );
++
++ my $prj = QMake::Project->new( $makefile );
++
++ # Due to QTCREATORBUG-7170, we cannot let QMake::Project use jom
++ $prj->set_make( ($make =~ m{\bjom}i) ? 'nmake' : $make );
++
++ # Collect all interesting info about the tests.
++ my @qmake_tests = qw(
++ parallel_test
++ insignificant_test
++ );
++ my @qmake_scalar_values = qw(
++ TARGET
++ testcase.timeout
++ );
++ my @qmake_keys = (@qmake_tests, @qmake_scalar_values);
++
++ my %info = (
++ args => [ $testcase, @args ],
++ cwd => $CWD,
++ map( { my $v = $prj->test( $_ ); $_ => $v } @qmake_tests),
++ map( { my $v = $prj->values( $_ ); $_ => $v } @qmake_scalar_values),
++ );
++
++ # flatten info before passing to Data::Dumper
++ @info{ @qmake_keys } = apply { $_ = force $_ } @info{ @qmake_keys };
++
++ # Eliminate any undefined values
++ if (my @undefined = grep { !defined( $info{ $_ }) } @qmake_keys) {
++ delete @info{ @undefined };
++ }
++
++ # add a nice "label", which is the primary human-readable name for the
++ # test in test reports.
++ $info{ label } = basename( $info{ TARGET } );
++
++ # Now write the info to the testplan.
++ $self->write_testcase( \%info );
++
++ print " testplan: $info{ label }\n";
++
++ return;
++ }
++
++ # Write all of the given testcase @info (array of hashrefs) to the output file.
++ sub write_testcase
++ {
++ my ($self, @info) = @_;
++
++ my $output = $self->{ output };
++
++ my @info_strings = map { $self->testcase_to_string( $_ ) } @info;
++ my $text = join( "\n", @info_strings )."\n";
++
++ # Now write the info to the testplan (single line).
++ open( my $fh, '>>', $output );
++ flock( $fh, LOCK_EX );
++ seek( $fh, 0, SEEK_END );
++ print $fh $text;
++ flock( $fh, LOCK_UN );
++ close( $fh );
++
++ return;
++ }
++
++ # Given a testcase $info hashref, returns a serialized string representing
++ # the info. Guaranteed not to contain any newlines.
++ sub testcase_to_string
++ {
++ my ($self, $info) = @_;
++
++ my $dumper = Data::Dumper->new( [ $info ] );
++ $dumper->Indent( 0 ); # all output on one line
++ $dumper->Terse( 1 ); # omit leading $VAR1
++ $dumper->Sortkeys( 1 ); # get a predictable order
++ $dumper->Useqq( 1 ); # handle special characters safely (although none are expected)
++
++ my $info_string = $dumper->Dump( );
++
++ # trivial sanity check: should be just one line
++ if ($info_string =~ m{\n}) {
++ die "internal error: multiple lines in testcase info string:\n$info_string";
++ }
++
++ return $info_string;
++ }
++
++ sub run_make_check
++ {
++ my ($self, @args) = @_;
++
++ local $CWD = $self->{ input };
++
++ # We are going to pass TESTRUNNER and TESTARGS to `make check'.
++ # If these are already set in the environment, they may interfere with
++ # our own values, so remove them.
++ # In practice, this occurs when `nmake check' is used to run the selftests in
++ # the qtqa repository.
++ my %clean_env = %ENV;
++ delete @clean_env{qw(TESTARGS TESTRUNNER)};
++ local %ENV = %clean_env;
++
++ my $make = $self->{ make };
++ my $output = $self->{ output };
++ my $this_script = $self->{ this_script };
++
++ my @command = ( $make );
++
++ my $make_flavor = $self->make_flavor( );
++
++ if ($make_flavor eq 'GNU') {
++ push @command, '-s', '-j4';
++ } elsif ($make_flavor eq 'MS') {
++ push @command, '/NOLOGO', '/S';
++ if ($make =~ m{\bjom}i) {
++ push @command, '-j4';
++ }
++ } else {
++ warn "Unknown make command $make. May be slow and noisy.\n";
++ }
++
++ my $makefile_var = $self->makefile_var( );
++ my $subcmd = "$EXECUTABLE_NAME $this_script --make $make --makefile $makefile_var --output $output --testcase";
++
++ push @command, (
++ 'check',
++ "TESTRUNNER=$subcmd --",
++ "TESTARGS=".join(' ', @args), # note: we know there are no spaces in any of @args
++ );
++
++ if (my $status = system( @command )) {
++ die "testplan generation failed; @command exited with status $status (exit code ".($status >> 8).')';
++ }
++
++ return;
++ }
++
++ #==================================================================================================
++
++ QtQA::App::TestPlanner->new( )->run( @ARGV ) if (!caller);
++ 1;
++