Revision control

Copy as Markdown

#!/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;