Signed-off-by: Francois Gouget fgouget@codeweavers.com --- testbot/lib/WineTestBot/LogUtils.pm | 60 ++++++++++++++--------------- 1 file changed, 30 insertions(+), 30 deletions(-)
diff --git a/testbot/lib/WineTestBot/LogUtils.pm b/testbot/lib/WineTestBot/LogUtils.pm index 77837fbf..1e7dcda8 100644 --- a/testbot/lib/WineTestBot/LogUtils.pm +++ b/testbot/lib/WineTestBot/LogUtils.pm @@ -822,6 +822,7 @@ sub GetLogLabel($) return defined $Label ? sprintf($Label, $Extra) : $LogFileName; }
+ # # Log errors caching [Part 1] # @@ -833,10 +834,36 @@ sub GetLogLabel($)
Loads the specified log errors file.
-See _WriteLogErrors() for the format of the errors file. - Returns the errors in the same format as TagNewErrors().
+All lines are of the following form: + <type> <value1> <value2> + +The values depend on the <type> of the line. <type> and <value1> must not +contain spaces while <value2> runs to the end of the line. +More specifically the line formats are: +=over + +=item p <name> <value> +Property lines contain (name, value) pairs. +Note that properties which can be calculated while reading the errors file +are not saved (e.g. ErrCount and NewCount). + +=item g <lineno> <groupname> +Group lines contain the group name and the line number of the first line of +the group in the log. Note that the first line would typically not be an +error line. +A group line should be followed by the old and new error lines in this group. + +=item o <lineno> <line> +Old error lines contain the line number of the error in the log and a verbatim +copy of that line (with CR/LF converted to a simple LF). + +=item n <lineno> <line> +The format for new error lines is identical to that for old errors but with a +different type. + +=back =back =cut
@@ -919,35 +946,8 @@ sub LoadLogErrors($) =item C<_WriteLogErrors()>
Writes the LogInfo structure in text form to the specified file descriptor. +See _LoadLogErrors() for the format of the errors file.
-All lines follow are of the following form: - <type> <value1> <value2> - -The values depend on the <type> of the line. <type> and <value1> must not -contain spaces while <value2> runs to the end of the line. More specifically -the line formats are: -=over - -=item p <name> <value> -Property lines contain (name, value) pairs. -Note that properties which can be calculated while reading the errors file -are not saved (e.g. ErrCount and NewCount). - -=item g <lineno> <groupname> -Group lines contain the group name and the line number of the first line of -the group in the log. Note that the first line would typically not be an -error line. -A group line should be followed by the old and new error lines in this group. - -=item o <lineno> <line> -Old error lines contain the line number of the error in the log and a verbatim -copy of that line (with CR/LF converted to a simple LF). - -=item n <lineno> <line> -The format for new error lines is identical to that for old errors but with a -different type. - -=back =back =cut
Furthermore LoadLogErrorsFromFh() returns unsupported lines to the caller (in addition to setting BadLog) which allows embedding .errors-formatted sections in other text files.
Signed-off-by: Francois Gouget fgouget@codeweavers.com --- testbot/lib/WineTestBot/LogUtils.pm | 99 +++++++++++++++++------------ 1 file changed, 60 insertions(+), 39 deletions(-)
diff --git a/testbot/lib/WineTestBot/LogUtils.pm b/testbot/lib/WineTestBot/LogUtils.pm index 1e7dcda8..17be624e 100644 --- a/testbot/lib/WineTestBot/LogUtils.pm +++ b/testbot/lib/WineTestBot/LogUtils.pm @@ -31,7 +31,7 @@ our @EXPORT = qw(GetLogFileNames GetLogLabel GetLogLineCategory GetReportLineCategory ParseTaskLog ParseWineTestReport SnapshotLatestReport UpdateLatestReport UpdateLatestReports - CreateLogErrorsCache LoadLogErrors); + CreateLogErrorsCache LoadLogErrorsFromFh LoadLogErrors);
use Algorithm::Diff; use File::Basename; @@ -830,11 +830,10 @@ sub GetLogLabel($) =pod =over 12
-=item C<LoadLogErrors()> - -Loads the specified log errors file. +=item C<LoadLogErrorsFromFh()>
-Returns the errors in the same format as TagNewErrors(). +Loads the specified log errors file, returning the errors in the same format +as TagNewErrors().
All lines are of the following form: <type> <value1> <value2> @@ -867,37 +866,23 @@ different type. =back =cut
-sub LoadLogErrors($) +sub LoadLogErrorsFromFh($$) { - my ($LogPath) = @_; + my ($LogInfo, $ErrorsFile) = @_;
- my $LogName = basename($LogPath); - my $LogInfo = { - LogName => $LogName, - LogPath => $LogPath, + $LogInfo->{ErrGroupNames} ||= []; + $LogInfo->{ErrGroups} ||= {};
- ErrGroupNames => [], - ErrGroups => {}, - }; - $LogPath .= ".errors"; - - my $ErrorsFile; - if (!open($ErrorsFile, "<", $LogPath)) - { - $LogInfo->{BadLog} = "Unable to open '$LogName.errors' for reading: $!"; - return $LogInfo; - } - - my ($LineNo, $CurGroup); - foreach my $Line (<$ErrorsFile>) + while (my $Line = <$ErrorsFile>) { - $LineNo++; + $LogInfo->{LineNo}++; chomp $Line; + my ($Type, $Property, $Value) = split / /, $Line, 3; if (!defined $Value) { - $LogInfo->{BadLog} = "$LineNo: Found an invalid line"; - last; + $LogInfo->{BadLog} = "$LogInfo->{LineNo}: Found an invalid line"; + return $Line; } # else $Type, $Property and $Value are all defined elsif ($Type eq "p") @@ -908,34 +893,70 @@ sub LoadLogErrors($) } else { - $LogInfo->{BadLog} = "$LineNo: Cannot set $Property = $Value because it is already set to $LogInfo->{$Property}"; - last; + $LogInfo->{BadLog} = "$LogInfo->{LineNo}: Cannot set $Property = $Value because it is already set to $LogInfo->{$Property}"; + return $Line; } } elsif ($Type eq "g") { - $CurGroup = _AddLogGroup($LogInfo, $Value, $Property); + $LogInfo->{CurGroup} = _AddLogGroup($LogInfo, $Value, $Property); } - elsif (!$CurGroup) + elsif (!$LogInfo->{CurGroup}) { - $LogInfo->{BadLog} = "$LineNo: Got a $Type line with no group"; - last; + $LogInfo->{BadLog} = "$LogInfo->{LineNo}: Got a $Type line with no group"; + return $Line; } elsif ($Type eq "o") { - _AddLogError($LogInfo, $CurGroup, $Value, $Property); + _AddLogError($LogInfo, $LogInfo->{CurGroup}, $Value, $Property); } elsif ($Type eq "n") { - _AddLogError($LogInfo, $CurGroup, $Value, $Property, "new"); + _AddLogError($LogInfo, $LogInfo->{CurGroup}, $Value, $Property, "new"); } else { - $LogInfo->{BadLog} = "$LineNo: Found an unknown line type ($Type)"; - last; + $LogInfo->{BadLog} = "$LogInfo->{LineNo}: Found an unknown line type ($Type)"; + return $Line; } } - close($ErrorsFile); + + return undef; +} + +=pod +=over 12 + +=item C<LoadLogErrors()> + +Loads the specified log errors file. + +See _LoadLogErrorsFromFh() for the format of the errors file. + +Returns the errors in the same format as TagNewErrors(). + +=back +=cut + +sub LoadLogErrors($) +{ + my ($LogPath) = @_; + + my $LogInfo = { + LogName => basename($LogPath), + LogPath => $LogPath, + }; + if (open(my $ErrorsFile, "<", "$LogPath.errors")) + { + LoadLogErrorsFromFh($LogInfo, $ErrorsFile); + delete $LogInfo->{CurGroup}; + close($ErrorsFile); + } + else + { + $LogInfo->{BadLog} = "Unable to open '$LogInfo->{LogName}.errors' for reading: $!"; + return $LogInfo; + }
return $LogInfo; }
This way its name is consistent with LoadLogErrorsFromFh().
Signed-off-by: Francois Gouget fgouget@codeweavers.com --- testbot/lib/WineTestBot/LogUtils.pm | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/testbot/lib/WineTestBot/LogUtils.pm b/testbot/lib/WineTestBot/LogUtils.pm index 17be624e..30038cf6 100644 --- a/testbot/lib/WineTestBot/LogUtils.pm +++ b/testbot/lib/WineTestBot/LogUtils.pm @@ -964,7 +964,7 @@ sub LoadLogErrors($) =pod =over 12
-=item C<_WriteLogErrors()> +=item C<_WriteLogErrorsToFh()>
Writes the LogInfo structure in text form to the specified file descriptor. See _LoadLogErrors() for the format of the errors file. @@ -972,7 +972,7 @@ See _LoadLogErrors() for the format of the errors file. =back =cut
-sub _WriteLogErrors($$) +sub _WriteLogErrorsToFh($$) { my ($Fh, $LogInfo) = @_;
@@ -1000,7 +1000,7 @@ sub _SaveLogErrors($) my $ErrorsPath = "$LogInfo->{LogPath}.errors"; if (open(my $ErrorsFile, ">", $ErrorsPath)) { - _WriteLogErrors($ErrorsFile, $LogInfo); + _WriteLogErrorsToFh($ErrorsFile, $LogInfo); close($ErrorsFile);
# Set the mtime so Janitor reaps both at the same time @@ -1020,7 +1020,7 @@ sub _DumpErrors($$) next if ($Key =~ /^(?:ErrGroupNames|ErrGroups|NewCount)$/); print STDERR "+ $Key $LogInfo->{$Key}\n"; } - _WriteLogErrors(*STDERR, $LogInfo); + _WriteLogErrorsToFh(*STDERR, $LogInfo); }
Signed-off-by: Francois Gouget fgouget@codeweavers.com --- testbot/lib/WineTestBot/LogUtils.pm | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/testbot/lib/WineTestBot/LogUtils.pm b/testbot/lib/WineTestBot/LogUtils.pm index 30038cf6..3488c7b2 100644 --- a/testbot/lib/WineTestBot/LogUtils.pm +++ b/testbot/lib/WineTestBot/LogUtils.pm @@ -1010,17 +1010,25 @@ sub _SaveLogErrors($) return "Could not open '$LogInfo->{LogName}.errors' for writing: $!"; }
-sub _DumpErrors($$) +sub _DumpErrors { my ($Label, $LogInfo) = @_;
print STDERR "$Label:\n"; + my @SubKeys; foreach my $Key (sort keys %{$LogInfo}) { next if ($Key =~ /^(?:ErrGroupNames|ErrGroups|NewCount)$/); + if (ref($LogInfo->{$Key}) eq "HASH") + { + push @SubKeys, $Key; + next; + } + print STDERR "+ $Key $LogInfo->{$Key}\n"; } _WriteLogErrorsToFh(*STDERR, $LogInfo); + map { _DumpErrors("$Label.$_", $LogInfo->{$_}) } (@SubKeys); }
The Wine TestBot test Suite is a series of Wine pactches that can be used to verify that the TestBot handles test failures, timeouts, etc as expected. The TestWTBS script looks for directives in the commit message of these patches to automate a range of checks on the corresponding jobs and tasks. To simplify parsing the directives, reuse the TestBot's simple errors cache format.
For instance: ----- TestWTBS ----- p job.Status completed p tasks.Status completed p tests.TestFailures 1 ----- TestWTBS -----
Signed-off-by: Francois Gouget fgouget@codeweavers.com ---
The TestWTBS script does not include patches to send to the TestBot for testing. There is also no 'official' set of patches but you can find the ones I use there:
https://github.com/fgouget/wine/tree/wtbsuite
This test suite covers many aspects such as checking that the TestBot rebuilds Wine correctly no matter which file is patched, runs the right set of tests, handles timeouts correctly, keeps track of concurrent patch series, etc. As a result this test suite is quite large, resulting in about 127 TestBot jobs where, for each one, one should check each task's status, list of errors, reported failure count, etc. That's tedious, time counsuming, error prone, and thus lead to the need for automation.
testbot/tests/TestWTBS | 459 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100755 testbot/tests/TestWTBS
diff --git a/testbot/tests/TestWTBS b/testbot/tests/TestWTBS new file mode 100755 index 00000000..786d3692 --- /dev/null +++ b/testbot/tests/TestWTBS @@ -0,0 +1,459 @@ +#!/usr/bin/perl +# -*- Mode: Perl; perl-indent-level: 2; indent-tabs-mode: nil -*- +# +# Automate checking the Wine TestBot test Suite results. +# +# Copyright 2020 Francois Gouget +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + +use strict; +use warnings; + +sub BEGIN +{ + if ($0 !~ m=^/=) + { + # Turn $0 into an absolute path so it can safely be used in @INC + require Cwd; + $0 = Cwd::cwd() . "/$0"; + } + if ($0 =~ m=^(/.*)/[^/]+/[^/]+$=) + { + $::RootDir = $1; + unshift @INC, "$::RootDir/lib"; + } +} + +my $name0 = $0; +$name0 =~ s+^.*/++; + +use Algorithm::Diff; +use File::Basename; +use Test::More; + +use WineTestBot::Config; # For $PatchesMailingList +use WineTestBot::Jobs; +use WineTestBot::Log; +use WineTestBot::LogUtils; +use WineTestBot::VMs; + + +sub error(@) +{ + print STDERR "$name0:error: ", @_; +} + + +# +# Process the command line +# + +=pod + +This script automates checking the Wine TestBot test Suite results. + +The Wine TestBot test Suite is a series of Wine patches that can be used to +verify that the TestBot handles test failures, timeouts, and other conditions +as expected. + +This script looks for directives in the commit message of these patches to +automate a range of checks on the corresponding jobs and tasks. + +To simplify parsing the directives this script reuses the TestBot's errors +cache format (see LogUtils::LoadLogErrorsFromFh()). For each property the +special value 'ignore' causes that check to be skipped rather than using the +defaults. + +The directives must be enclosed in "----- TestWTBS -----" lines. For instance: +----- TestWTBS ----- +p job.Status completed +p tasks.Status completed +p tests.TestFailures 1 +----- TestWTBS ----- + +=cut + +my $Usage; +while (@ARGV) +{ + my $Arg = shift @ARGV; + if ($Arg eq "--help") + { + $Usage = 0; + } + else + { + error("unexpected argument '$Arg'\n"); + $Usage = 2; + } +} + +if (defined $Usage) +{ + if ($Usage) + { + error("try '$name0 --help' for more information\n"); + exit $Usage; + } + print "Usage: $name0 [--help]\n"; + print "\n"; + print "Tests the Patches subject parser.\n"; + print "\n"; + print "Where:\n"; + print " --help Shows this usage message.\n"; + exit 0; +} + + +# +# Test data retrieval +# + +=pod +=item <Categories> + +Property names are of the form 'category.name' where the category is one of: +- patch, job or tasks for checks to perform on the Patch, Job or Task objects + respectively. +- build for checks to perform on the build task. +- win32, win64 for checks to perform on the Windows 32- or 64-bit test results + respectively. +- win for the checks to perform on both the 32- and 64-bit test results; that + is equivalent to duplicating the directive for the win32 and win64 + categories. +- wine for the checks to perform on the Wine test results. +- tests for checks to perform on all test results, that is equivalent to + duplicating the directive for the win32, win64 and wine categories (but not + build since it does not run the tests). + +A corollary is that any property which is documented as being valid for a +category can also be specified for any of its subcategories. So for instance if +tasks.Foo is valid, then tests.Foo and win32.Foo are also valid. + +=cut + +sub DumpTestInfo($) +{ + my ($TestInfo) = @_; + + foreach my $Category ("patch", "job", "tasks", "tests", "win", + "build", "win32", "win64", "wine") + { + WineTestBot::LogUtils::_WriteLogErrorsToFh(*STDERR, $TestInfo->{$Category}); + WineTestBot::LogUtils::_DumpErrors($Category, $TestInfo->{$Category}); + } +} + +sub SetDefault($$$$) +{ + my ($TestInfo, $Category, $Field, $Default) = @_; + if (!defined $TestInfo->{$Category}->{$Field}) + { + $TestInfo->{$Category}->{$Field} = $Default; + } +} + +sub CheckValue($) +{ + my ($Value) = @_; + return (defined $Value and $Value ne "ignore"); +} + +sub LoadTestInfo($) +{ + my ($FileName) = @_; + + my $RawInfo; + if (open(my $TestFh, "<", $FileName)) + { + parser: while (my $Line = <$TestFh>) + { + $RawInfo->{LineNo}++; + chomp $Line; + + if ($Line eq "----- TestWTBS -----") + { + # Patch series may have multiple TestWTBS sections, one per part. + # Only the last one counts. + $RawInfo = { + LogName => basename($FileName), + LogPath => $FileName, + LineNo => $RawInfo->{LineNo}, + }; + while (1) + { + $Line = LoadLogErrorsFromFh($RawInfo, $TestFh); + if (!defined $Line) + { + $RawInfo->{BadLog} = "Reached an unexpected end-of-file at line $RawInfo->{LineNo}"; + last; + } + if ($Line eq "----- TestWTBS -----") + { + delete $RawInfo->{BadLog}; + last; + } + last parser if ($Line !~ /^\s*(?:;|$)/); + delete $RawInfo->{BadLog}; + } + } + } + close($TestFh); + + if ($RawInfo->{BadLog}) + { + fail("$FileName is not valid: $RawInfo->{BadLog}"); + return undef; + } + } + else + { + fail("Could not open $FileName for reading: $!"); + return undef; + } + + # Split up the information to jobs, tasks, etc. + my $TestInfo = { + patch => {}, job => {}, + tasks => {}, tests => {}, win => {}, + build => {}, win32 => {}, win64 => {}, wine => {}, + }; + my $HasTestInfo; + foreach my $Entry (keys %{$RawInfo}) + { + my $Field = lcfirst($Entry); + if ($Field =~ s/^(patch|job|tasks|tests|win|build|win32|win64|wine).//) + { + my $TaskType = $1; + $TestInfo->{$TaskType}->{$Field} = $RawInfo->{$Entry}; + $HasTestInfo = 1; + } + elsif ($Entry !~ /^(?:CurGroup|ErrCount|ErrGroups|ErrGroupNames|LineNo|LogName|LogPath|NewCount)$/) + { + fail("$FileName: $Entry is not a valid property name"); + } + } + return undef if (!$HasTestInfo); + + # Fill in some useful defaults + SetDefault($TestInfo, "job", "Status", "completed"); + SetDefault($TestInfo, "tasks", "Status", "completed"); + SetDefault($TestInfo, "tasks", "HasTask", 1); + + # Then propagate the defaults + foreach my $Pair (["win", ["win32", "win64"]], + ["tests", ["win32", "win64", "wine"]], + ["tasks", ["build", "win32", "win64", "wine"]]) + { + my ($Src, $TaskTypes) = @$Pair; + foreach my $Field (keys %{$TestInfo->{$Src}}) + { + foreach my $TaskType (@$TaskTypes) + { + my $TaskInfo = $TestInfo->{$TaskType}; + if (!defined $TaskInfo->{$Field}) + { + $TaskInfo->{$Field} = $TestInfo->{$Src}->{$Field}; + } + } + } + } + + return $TestInfo; +} + + +# +# Verify the Jobs and Tasks +# + +my $HasBaseVM; + +sub TaskKeyStr($) +{ + my ($Task) = @_; + return join("/", @{$Task->GetMasterKey()}); +} + +sub IsMailingListJob($) +{ + my ($Job) = @_; + return $Job->Remarks =~ /^[\Q$PatchesMailingList\E] /; +} + +=pod + +=item <job.Status> +=item <tasks.Status> + +Checks the value of the job or tasks' Status field. Note that jobs that are +still queued or running or have been canceled are automatically skipped to +avoid false positives. +By default the Status field should be 'completed'. + +=item <tasks.TestFailures> + +Checks the value of the task's TestFailures field. +By default the TestFailures field is not checked. + +=cut + +sub CheckTask($$$) +{ + my ($Task, $TaskType, $TestInfo) = @_; + + my $TaskInfo = $TestInfo->{$TaskType}; + if (CheckValue($TaskInfo->{Status})) + { + is($Task->Status, $TaskInfo->{Status}, "Check Status of task ". TaskKeyStr($Task)); + } + + my $ReportCount = 0; + foreach my $LogName (@{GetLogFileNames($Task->GetDir())}) + { + my $LogPath = $Task->GetDir() ."/$LogName"; + my $LogInfo = LoadLogErrors($LogPath); + + if ($LogName =~ /.report$/) + { + $ReportCount++; + } + } + if ($Task->Status eq "completed" and CheckValue($TaskInfo->{TestFailures})) + { + # Scale the expected TestFailures count with the number of times the test + # was run, i.e. $ReportCount, or take it as is if no report is available. + is($Task->TestFailures, $TaskInfo->{TestFailures} * ($ReportCount || 1), "Check Failures of task ". TaskKeyStr($Task)); + } +} + +=pod + +=item <job.Remarks> + +Checks that the job's Remarks field matches the specified value. By default +it should match the patch's subject, modulo the mailing list name. Specifying +job.Remarks is only necessary to deal with some patch series. + +=cut + +sub CheckJob($$) +{ + my ($Job, $TestInfo) = @_; + + print $Job->Id ." Checking ". $Job->Remarks ."\n"; + my $JobInfo = $TestInfo->{job}; + if (CheckValue($JobInfo->{Remarks})) + { + my $Remarks = $Job->Remarks; + # Remove the mailing-list prefix if present + $Remarks =~ s/^[\Q$PatchesMailingList\E] //; + is($Remarks, $JobInfo->{Remarks}, "Check Remarks for job ". $Job->Id); + } + if (CheckValue($JobInfo->{Status})) + { + is($Job->Status, $JobInfo->{Status}, "Check Status for job ". $Job->Id); + } +} + +=pod + +=item <tasks.HasTask> + +Set to 1 if the job must have a task matching the specified category if the +TestBot has the relevant type of VM. Set to 0 if it must not have such a task. +By default all types of task are expected to be present. + +For instance if the patch does not impact the Windows test results add the +lines below to make sure the TestBot did not try to run the tests on Windows. + +p build.HasTask 0 +p win.HasTask 0 + +=cut + +sub CheckJobTree($) +{ + my ($Job) = @_; + + my ($TestInfo, $HasTask); + + my $Steps = $Job->Steps; + foreach my $Step (sort { $a->No <=> $b->No } @{$Job->Steps->GetItems()}) + { + if (!$TestInfo) + { + # WTBS jobs all have a patch + return if ($Step->FileType ne "patch"); + + $TestInfo = LoadTestInfo($Step->GetFullFileName()); + return if (!$TestInfo); + } + + my $TaskType = $Step->Type eq "build" ? "build" : + $Step->FileType eq "patch" ? "wine" : + $Step->FileType eq "exe32" ? "win32" : "win64"; + + foreach my $Task (sort { $a->No <=> $b->No } @{$Step->Tasks->GetItems()}) + { + $HasTask->{$TaskType} = 1; + CheckTask($Task, $TaskType, $TestInfo); + } + } + CheckJob($Job, $TestInfo); + + # Ignore manually submitted jobs because they allow the developer to pick + # which VMs to run the test on. + if (IsMailingListJob($Job)) + { + foreach my $Type ("win32", "win64", "wine") + { + next if (!$HasBaseVM->{$Type}); + + my $TypeInfo = $TestInfo->{$Type}; + if (CheckValue($TypeInfo->{HasTask})) + { + $HasTask->{$Type} ||= 0; + is($HasTask->{$Type}, $TypeInfo->{HasTask}, "Check the presence of $Type tasks for job ". $Job->Id); + } + } + } +} + +sub CheckJobs() +{ + my $Jobs = CreateJobs(); + $Jobs->AddFilter("Status", ['completed', 'badpatch', 'badbuild', 'boterror']); + foreach my $Job (sort { $b->Id <=> $a->Id } @{$Jobs->GetItems()}) + { + CheckJobTree($Job); + } +} + + +# +# Run the tests +# + +my $VMs = CreateVMs(); +foreach my $VM (@{$VMs->GetItems()}) +{ + $HasBaseVM->{$VM->Type} = 1 if ($VM->Role eq "base"); +} + +CheckJobs(); + +done_testing();