Parser.pm 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. # ABSTRACT: Upload your TAP results to TestRail
  2. # PODNAME: Test::Rail::Parser
  3. package Test::Rail::Parser;
  4. use strict;
  5. use warnings;
  6. use utf8;
  7. use parent qw/TAP::Parser/;
  8. use Carp qw{cluck confess};
  9. use POSIX qw{floor strftime};
  10. use Clone qw{clone};
  11. use TestRail::API;
  12. use TestRail::Utils;
  13. use Scalar::Util qw{reftype};
  14. use File::Basename qw{basename};
  15. =head1 DESCRIPTION
  16. A TAP parser which will upload your test results to a TestRail install.
  17. Has several options as to how you might want to upload said results.
  18. Subclass of L<TAP::Parser>, see that for usage past the constructor.
  19. You should probably use L<App::Prove::Plugin::TestRail> or the bundled program testrail-report for day-to-day usage...
  20. unless you need to subclass this. In that case a couple of options have been exposed for your convenience.
  21. =cut
  22. =head1 CONSTRUCTOR
  23. =head2 B<new(OPTIONS)>
  24. Get the TAP Parser ready to talk to TestRail, and register a bunch of callbacks to upload test results.
  25. =over 4
  26. =item B<OPTIONS> - HASHREF -- Keys are as follows:
  27. =over 4
  28. =item B<apiurl> - STRING: Full URI to your TestRail installation.
  29. =item B<user> - STRING: Name of your TestRail user.
  30. =item B<pass> - STRING: Said user's password, or one of their valid API keys (TestRail 4.2 and above).
  31. =item B<debug> - BOOLEAN: Print a bunch of extra messages
  32. =item B<browser> - OBJECT: Something like an LWP::UserAgent. Useful for mocking.
  33. =item B<run> - STRING: name of desired run.
  34. =item B<plan> - STRING (semi-optional): Name of test plan to use, if your run provided is a child of said plan.
  35. =item B<configs> - ARRAYREF (optional): Configurations to filter runs in plan by. Runs can have the same name, yet with differing configurations in a plan; this handles that odd case.
  36. =item B<project> - STRING (optional): name of project containing your desired run. Required if project_id not passed.
  37. =item B<project_id> - INTEGER (optional): ID of project containing your desired run. Required if project not passed.
  38. =item B<step_results> - STRING (optional): 'internal name' of the 'step_results' type field available for your project.
  39. =item B<result_options> - HASHREF (optional): Extra options to set with your result. See L<TestRail::API>'s createTestResults function for more information.
  40. =item B<custom_options> - HASHREF (optional): Custom options to set with your result. See L<TestRail::API>'s createTestResults function for more information. step_results will be set here, if the option is passed.
  41. =item B<testsuite> - STRING (optional): Attempt to create a run based on the testsuite identified by the name passed here. If plan/configs are passed, create it as a child of said plan with the listed configs. If the run exists, use it and disregard this option. If the containing plan does not exist, create it too. Mutually exclusive with 'testsuite_id'.
  42. =item B<testsuite_id> - INTEGER (optional): Attempt to create a run based on the testsuite identified by the ID passed here. If plan/configs are passed, create it as a child of said plan with the listed configs. If the run exists, use it and disregard this option. If the plan does not exist, create it too. Mutually exclusive with 'testsuite'.
  43. =item B<sections> - ARRAYREF (optional): Restrict a spawned run to cases in these particular sections.
  44. =item B<autoclose> - BOOLEAN (optional): If no cases in the run/plan are marked 'Untested' or 'Retest', go ahead and close the run. Default false.
  45. =item B<encoding> - STRING (optional): Character encoding of TAP to be parsed and the various inputs parameters for the parser. Defaults to UTF-8, see L<Encode::Supported> for a list of supported encodings.
  46. =item B<test_bad_status> - STRING (optional): 'internal' name of whatever status you want to mark compile failures & no plan + no assertion tests.
  47. =item B<max_tries> - INTEGER (optional): number of times to try failing requests. Defaults to 1 (don't re-try).
  48. =back
  49. =back
  50. In both this mode and step_results, the file name of the test is expected to correspond to the test name in TestRail.
  51. This module also attempts to calculate the elapsed time to run each test if it is run by a prove plugin rather than on raw TAP.
  52. The constructor will terminate if the statuses 'pass', 'fail', 'retest', 'skip', 'todo_pass', and 'todo_fail' are not registered as result internal names in your TestRail install.
  53. The purpose of the retest status is somewhat special, as there is no way to set a test back to 'untested' in TestRail, and we use this to allow automation to pick back up if
  54. something needs re-work for whatever reason.
  55. The global status of the case will be set according to the following rules:
  56. 1. If there are no issues whatsoever besides TODO failing tests & skips, mark as PASS
  57. 2. If there are any non-skipped or TODOed fails OR a bad plan (extra/missing tests), mark as FAIL
  58. 3. If there are only SKIPs (e.g. plan => skip_all), mark as SKIP
  59. 4. If the only issues with the test are TODO tests that pass, mark as TODO PASS (to denote these TODOs for removal).
  60. 5. If no tests are run at all, and no plan made (such as a compile failure), the cases will be marked as failures unless you provide a test_bad status name in your testrailrc.
  61. Step results will always be whatever status is relevant to the particular step.
  62. =head1 TAP Extensions
  63. =head2 Forcing status reported
  64. A line that begins like so:
  65. % mark_status=
  66. Will allow you to force the status of a test case to whatever is on the right hand side of the = expression.
  67. Example (force test to retest in event of tool failure):
  68. my $failed = do_something_possibly_causing_tool_failure();
  69. print "% mark_status=retest" if $failed;
  70. Bogus statuses will cluck, but otherwise be ignored. Valid statuses are any of the required internal names in your TestRail install (see above).
  71. Multiple instances of this will ignore all but the latest valid status.
  72. =cut
  73. sub new {
  74. my ($class,$opts) = @_;
  75. $opts = clone $opts; #Convenience, if we are passing over and over again...
  76. #Load our callbacks
  77. $opts->{'callbacks'} = {
  78. 'test' => \&testCallback,
  79. 'comment' => \&commentCallback,
  80. 'unknown' => \&unknownCallback,
  81. 'bailout' => \&bailoutCallback,
  82. 'EOF' => \&EOFCallback,
  83. 'plan' => \&planCallback,
  84. };
  85. my $tropts = {
  86. 'apiurl' => delete $opts->{'apiurl'},
  87. 'user' => delete $opts->{'user'},
  88. 'pass' => delete $opts->{'pass'},
  89. 'debug' => delete $opts->{'debug'},
  90. 'browser' => delete $opts->{'browser'},
  91. 'run' => delete $opts->{'run'},
  92. 'project' => delete $opts->{'project'},
  93. 'project_id' => delete $opts->{'project_id'},
  94. 'step_results' => delete $opts->{'step_results'},
  95. 'plan' => delete $opts->{'plan'},
  96. 'plan_id' => delete $opts->{'plan_id'},
  97. 'configs' => delete $opts->{'configs'} // [],
  98. 'testsuite_id' => delete $opts->{'testsuite_id'},
  99. 'testsuite' => delete $opts->{'testsuite'},
  100. 'encoding' => delete $opts->{'encoding'},
  101. 'sections' => delete $opts->{'sections'},
  102. 'autoclose' => delete $opts->{'autoclose'},
  103. 'config_group' => delete $opts->{'config_group'},
  104. #Stubs for extension by subclassers
  105. 'result_options' => delete $opts->{'result_options'},
  106. 'result_custom_options' => delete $opts->{'result_custom_options'},
  107. 'test_bad_status' => delete $opts->{'test_bad_status'},
  108. 'max_tries' => delete $opts->{'max_tries'} || 1,
  109. };
  110. confess("plan passed, but no run passed!") if !$tropts->{'run'} && $tropts->{'plan'};
  111. #Allow natural confessing from constructor
  112. #Force-on POST redirects for maximum compatibility
  113. #Also ensure all opts that need string type have it when undef
  114. my $tr = TestRail::API->new(
  115. $tropts->{'apiurl'} // '',
  116. $tropts->{'user'} // '',
  117. $tropts->{'pass'} // '',
  118. $tropts->{'encoding'} // '',
  119. $tropts->{'debug'},
  120. 1,
  121. $tropts->{max_tries},
  122. { 'skip_usercache' => 1 },
  123. );
  124. $tropts->{'testrail'} = $tr;
  125. $tr->{'browser'} = $tropts->{'browser'} if defined($tropts->{'browser'}); #allow mocks
  126. $tr->{'debug'} = 0; #Always suppress in production
  127. #Get project ID from name, if not provided
  128. if (!defined($tropts->{'project_id'})) {
  129. my $pname = $tropts->{'project'};
  130. $tropts->{'project'} = $tr->getProjectByName($pname);
  131. confess("Could not list projects! Shutting down.") if ($tropts->{'project'} == -500);
  132. if (!$tropts->{'project'}) {
  133. confess("No project (or project_id) provided, or that which was provided was invalid!");
  134. }
  135. } else {
  136. $tropts->{'project'} = $tr->getProjectByID($tropts->{'project_id'});
  137. confess("No such project with ID $tropts->{project_id}!") if !$tropts->{'project'};
  138. }
  139. $tropts->{'project_id'} = $tropts->{'project'}->{'id'};
  140. # Ok, let's cache the users since we have the project ID now
  141. $tr->getUsers($tropts->{'project_id'});
  142. #Discover possible test statuses
  143. $tropts->{'statuses'} = $tr->getPossibleTestStatuses();
  144. my @ok = grep {$_->{'name'} eq 'passed'} @{$tropts->{'statuses'}};
  145. my @not_ok = grep {$_->{'name'} eq 'failed'} @{$tropts->{'statuses'}};
  146. my @skip = grep {$_->{'name'} eq 'skip'} @{$tropts->{'statuses'}};
  147. my @todof = grep {$_->{'name'} eq 'todo_fail'} @{$tropts->{'statuses'}};
  148. my @todop = grep {$_->{'name'} eq 'todo_pass'} @{$tropts->{'statuses'}};
  149. my @retest = grep {$_->{'name'} eq 'retest'} @{$tropts->{'statuses'}};
  150. my @tbad;
  151. @tbad = grep {$_->{'name'} eq $tropts->{test_bad_status} } @{$tropts->{'statuses'}} if $tropts->{test_bad_status};
  152. confess("No status with internal name 'passed' in TestRail!") unless scalar(@ok);
  153. confess("No status with internal name 'failed' in TestRail!") unless scalar(@not_ok);
  154. confess("No status with internal name 'skip' in TestRail!") unless scalar(@skip);
  155. confess("No status with internal name 'todo_fail' in TestRail!") unless scalar(@todof);
  156. confess("No status with internal name 'todo_pass' in TestRail!") unless scalar(@todop);
  157. confess("No status with internal name 'retest' in TestRail!") unless scalar(@retest);
  158. confess("No status with internal name '$tropts->{test_bad_status}' in TestRail!") unless scalar(@tbad) || !$tropts->{test_bad_status};
  159. #Map in all the statuses
  160. foreach my $status (@{$tropts->{'statuses'}}) {
  161. $tropts->{$status->{'name'}} = $status;
  162. }
  163. #Special aliases
  164. $tropts->{'ok'} = $ok[0];
  165. $tropts->{'not_ok'} = $not_ok[0];
  166. confess "testsuite and testsuite_id are mutually exclusive" if ( $tropts->{'testsuite_id'} && $tropts->{'testsuite'});
  167. #Grab testsuite by name if needed
  168. if ($tropts->{'testsuite'}) {
  169. my $ts = $tr->getTestSuiteByName($tropts->{'project_id'},$tropts->{'testsuite'});
  170. confess("No such testsuite '".$tropts->{'testsuite'}."' found!") unless $ts;
  171. $tropts->{'testsuite_id'} = $ts->{'id'};
  172. }
  173. #Grab run
  174. my ($run,$plan,$config_ids);
  175. # See if we have to create a configuration
  176. my $configz2create = $tr->getConfigurations($tropts->{'project_id'});
  177. @$configz2create = grep { my $c = $_; (grep { $_ eq $c->{'name'} } @{$tropts->{'configs'}}) } @$configz2create;
  178. if (scalar(@$configz2create) && $tropts->{'config_group'}) {
  179. my $cgroup = $tr->getConfigurationGroupByName($tropts->{project_id},$tropts->{'config_group'});
  180. unless (ref($cgroup) eq 'HASH') {
  181. print "# Adding Configuration Group $tropts->{config_group}...\n";
  182. $cgroup = $tr->addConfigurationGroup($tropts->{project_id},$tropts->{'config_group'});
  183. }
  184. confess("Could neither find nor create the provided configuration group '$tropts->{config_group}'") unless ref($cgroup) eq 'HASH';
  185. foreach my $cc (@$configz2create) {
  186. print "# Adding Configuration $cc->{name}...\n";
  187. $tr->addConfiguration($cgroup->{'id'}, $cc->{'name'});
  188. }
  189. }
  190. #check if configs passed are defined for project. If we can't get all the IDs, something's hinky
  191. @$config_ids = $tr->translateConfigNamesToIds($tropts->{'project_id'},@{$tropts->{'configs'}});
  192. confess("Could not retrieve list of valid configurations for your project.") unless (reftype($config_ids) || 'undef') eq 'ARRAY';
  193. my @bogus_configs = grep {!defined($_)} @$config_ids;
  194. my $num_bogus = scalar(@bogus_configs);
  195. confess("Detected $num_bogus bad config names passed. Check available configurations for your project.") if $num_bogus;
  196. if ($tropts->{'plan'}) {
  197. #Attempt to find run, filtered by configurations
  198. if ( $tropts->{'plan_id'} ) {
  199. $plan = $tr->getPlanByID( $tropts->{'plan_id'} );
  200. } else {
  201. $plan = $tr->getPlanByName( $tropts->{'project_id'}, $tropts->{'plan'} );
  202. }
  203. confess("Test plan provided is completed, and spawning was not indicated") if (ref $plan eq 'HASH') && $plan->{'is_completed'} && (!$tropts->{'testsuite_id'});
  204. if ($plan && !$plan->{'is_completed'}) {
  205. $tropts->{'plan'} = $plan;
  206. $run = $tr->getChildRunByName($plan,$tropts->{'run'},$tropts->{'configs'}); #Find plan filtered by configs
  207. if (defined($run) && (reftype($run) || 'undef') eq 'HASH') {
  208. $tropts->{'run'} = $run;
  209. $tropts->{'run_id'} = $run->{'id'};
  210. }
  211. } else {
  212. #Try to make it if spawn is passed
  213. $tropts->{'plan'} = $tr->createPlan($tropts->{'project_id'},$tropts->{'plan'},"Test plan created by TestRail::API") if $tropts->{'testsuite_id'};
  214. confess("Could not find plan ".$tropts->{'plan'}." in provided project, and spawning failed (or was not indicated)!") if !$tropts->{'plan'};
  215. }
  216. } else {
  217. $run = $tr->getRunByName($tropts->{'project_id'},$tropts->{'run'});
  218. confess("Test run provided is completed, and spawning was not indicated") if (ref $run eq 'HASH') && $run->{'is_completed'} && (!$tropts->{'testsuite_id'});
  219. if (defined($run) && (reftype($run) || 'undef') eq 'HASH' && !$run->{'is_completed'}) {
  220. $tropts->{'run'} = $run;
  221. $tropts->{'run_id'} = $run->{'id'};
  222. }
  223. }
  224. #If spawn was passed and we don't have a Run ID yet, go ahead and make it
  225. if ($tropts->{'testsuite_id'} && !$tropts->{'run_id'}) {
  226. print "# Spawning run\n";
  227. my $cases = [];
  228. if ($tropts->{'sections'}) {
  229. print "# with specified sections\n";
  230. #Then translate the sections into an array of case IDs.
  231. confess("Sections passed to spawn must be ARRAYREF") unless (reftype($tropts->{'sections'}) || 'undef') eq 'ARRAY';
  232. @{$tropts->{'sections'}} = $tr->sectionNamesToIds($tropts->{'project_id'},$tropts->{'testsuite_id'},@{$tropts->{'sections'}});
  233. foreach my $section (@{$tropts->{'sections'}}) {
  234. #Get the child sections, and append them to our section list so we get their cases too.
  235. my $append_sections = $tr->getChildSections($tropts->{'project_id'}, { 'id' => $section, 'suite_id' => $tropts->{'testsuite_id'} } );
  236. @$append_sections = grep {my $sc = $_; !scalar(grep {$_ == $sc->{'id'}} @{$tropts->{'sections'}}) } @$append_sections; #de-dup in case the user added children to the list
  237. @$append_sections = map { $_->{'id'} } @$append_sections;
  238. push(@{$tropts->{'sections'}},@$append_sections);
  239. my $section_cases = $tr->getCases($tropts->{'project_id'},$tropts->{'testsuite_id'},{ 'section_id' => $section });
  240. push(@$cases,@$section_cases) if (reftype($section_cases) || 'undef') eq 'ARRAY';
  241. }
  242. }
  243. if (scalar(@$cases)) {
  244. @$cases = map {$_->{'id'}} @$cases;
  245. } else {
  246. $cases = undef;
  247. }
  248. if ($tropts->{'plan'}) {
  249. print "# inside of plan\n";
  250. $plan = $tr->createRunInPlan( $tropts->{'plan'}->{'id'}, $tropts->{'testsuite_id'}, $tropts->{'run'}, undef, $config_ids, $cases );
  251. $run = $plan->{'runs'}->[0] if exists($plan->{'runs'}) && (reftype($plan->{'runs'}) || 'undef') eq 'ARRAY' && scalar(@{$plan->{'runs'}});
  252. if (defined($run) && (reftype($run) || 'undef') eq 'HASH') {
  253. $tropts->{'run'} = $run;
  254. $tropts->{'run_id'} = $run->{'id'};
  255. }
  256. } else {
  257. $run = $tr->createRun( $tropts->{'project_id'}, $tropts->{'testsuite_id'}, $tropts->{'run'}, "Automatically created Run from TestRail::API", undef, undef, $cases );
  258. if (defined($run) && (reftype($run) || 'undef') eq 'HASH') {
  259. $tropts->{'run'} = $run;
  260. $tropts->{'run_id'} = $run->{'id'};
  261. }
  262. }
  263. confess("Could not spawn run with requested parameters!") if !$tropts->{'run_id'};
  264. print "# Success!\n"
  265. }
  266. confess("No run ID provided, and no run with specified name exists in provided project/plan!") if !$tropts->{'run_id'};
  267. my $self = $class->SUPER::new($opts);
  268. if (defined($self->{'_iterator'}->{'command'}) && reftype($self->{'_iterator'}->{'command'}) eq 'ARRAY' ) {
  269. $self->{'file'} = $self->{'_iterator'}->{'command'}->[-1];
  270. print "# PROCESSING RESULTS FROM TEST FILE: $self->{'file'}\n";
  271. $self->{'track_time'} = 1;
  272. } else {
  273. #Not running inside of prove in real-time, don't bother with tracking elapsed times.
  274. $self->{'track_time'} = 0;
  275. }
  276. #Make sure the step results field passed exists on the system
  277. my $sr_name = $tropts->{'step_results'};
  278. $tropts->{'step_results'} = $tr->getTestResultFieldByName($tropts->{'step_results'},$tropts->{'project_id'}) if defined $tropts->{'step_results'};
  279. confess("Invalid step results value '$sr_name' passed. Check the spelling and confirm that your project can use the '$sr_name' custom result field.") if ref $tropts->{'step_results'} ne 'HASH' && $sr_name;
  280. $self->{'tr_opts'} = $tropts;
  281. $self->{'errors'} = 0;
  282. #Start the shot clock
  283. $self->{'starttime'} = time();
  284. #Make sure we get the time it took to get to each step from the last correctly
  285. $self->{'lasttime'} = $self->{'starttime'};
  286. $self->{'raw_output'} = "";
  287. return $self;
  288. }
  289. =head1 PARSER CALLBACKS
  290. =head2 unknownCallback
  291. Called whenever we encounter an unknown line in TAP. Only useful for prove output, as we might pick a filename out of there.
  292. Stores said filename for future use if encountered.
  293. =cut
  294. # Look for file boundaries, etc.
  295. sub unknownCallback {
  296. my ($test) = @_;
  297. my $self = $test->{'parser'};
  298. my $line = $test->as_string;
  299. $self->{'raw_output'} .= "$line\n";
  300. #Unofficial "Extensions" to TAP
  301. my ($status_override) = $line =~ m/^% mark_status=([a-z|_]*)/;
  302. if ($status_override) {
  303. cluck "Unknown status override" unless defined $self->{'tr_opts'}->{$status_override}->{'id'};
  304. $self->{'global_status'} = $self->{'tr_opts'}->{$status_override}->{'id'} if $self->{'tr_opts'}->{$status_override};
  305. print "# Overriding status to $status_override (".$self->{'global_status'}.")...\n" if $self->{'global_status'};
  306. }
  307. #XXX I'd love to just rely on the 'name' attr in App::Prove::State::Result::Test, but...
  308. #try to pick out the filename if we are running this on TAP in files, where App::Prove is uninvolved
  309. my $file = TestRail::Utils::getFilenameFromTapLine($line);
  310. $self->{'file'} = $file if !$self->{'file'} && $file;
  311. return;
  312. }
  313. =head2 commentCallback
  314. Grabs comments preceding a test so that we can include that as the test's notes.
  315. Especially useful when merge=1 is passed to the constructor.
  316. =cut
  317. # Register the current suite or test desc for use by test callback, if the line begins with the special magic words
  318. sub commentCallback {
  319. my ($test) = @_;
  320. my $self = $test->{'parser'};
  321. my $line = $test->as_string;
  322. $self->{'raw_output'} .= "$line\n";
  323. if ($line =~ m/^#TESTDESC:\s*/) {
  324. $self->{'tr_opts'}->{'test_desc'} = $line;
  325. $self->{'tr_opts'}->{'test_desc'} =~ s/^#TESTDESC:\s*//g;
  326. }
  327. return;
  328. }
  329. =head2 testCallback
  330. If we are using step_results, append it to the step results array for use at EOF.
  331. Otherwise, do nothing.
  332. =cut
  333. sub testCallback {
  334. my ($test) = @_;
  335. my $self = $test->{'parser'};
  336. if ( $self->{'track_time'} ) {
  337. #Test done. Record elapsed time.
  338. my $tm = time();
  339. $self->{'tr_opts'}->{'result_options'}->{'elapsed'} = _compute_elapsed($self->{'lasttime'},$tm);
  340. $self->{'elapse_display'} = defined($self->{'tr_opts'}->{'result_options'}->{'elapsed'}) ? $self->{'tr_opts'}->{'result_options'}->{'elapsed'} : "0s";
  341. $self->{'lasttime'} = $tm;
  342. }
  343. my $line = $test->as_string;
  344. my $tline = $line;
  345. $tline = "[".strftime("%H:%M:%S %b %e %Y",localtime($self->{'lasttime'}))." ($self->{elapse_display})] $line" if $self->{'track_time'};
  346. $self->{'raw_output'} .= "$tline\n";
  347. #Don't do anything if we don't want to map TR case => ok or use step-by-step results
  348. if ( !$self->{'tr_opts'}->{'step_results'} ) {
  349. print "# step_results not set. No action to be taken, except on a whole test basis.\n" if $self->{'tr_opts'}->{'debug'};
  350. return 1;
  351. }
  352. $line =~ s/^(ok|not ok)\s[0-9]*\s-\s//g;
  353. my $test_name = $line;
  354. print "# Assuming test name is '$test_name'...\n" if $self->{'tr_opts'}->{'debug'} && !$self->{'tr_opts'}->{'step_results'};
  355. my $todo_reason;
  356. #Setup args to pass to function
  357. my $status = $self->{'tr_opts'}->{'not_ok'}->{'id'};
  358. my $status_name = 'NOT OK';
  359. if ($test->is_actual_ok()) {
  360. $status = $self->{'tr_opts'}->{'ok'}->{'id'};
  361. $status_name = 'OK';
  362. if ($test->has_skip()) {
  363. $status = $self->{'tr_opts'}->{'skip'}->{'id'};
  364. $status_name = 'SKIP';
  365. $test_name =~ s/^(ok|not ok)\s[0-9]*\s//g;
  366. $test_name =~ s/^# skip //gi;
  367. print "# '$test_name'\n";
  368. }
  369. if ($test->has_todo()) {
  370. $status = $self->{'tr_opts'}->{'todo_pass'}->{'id'};
  371. $status_name = 'TODO PASS';
  372. $test_name =~ s/^(ok|not ok)\s[0-9]*\s//g;
  373. $test_name =~ s/^# todo & skip //gi; #handle todo_skip
  374. $test_name =~ s/# todo\s(.*)$//gi;
  375. $todo_reason = $test->explanation();
  376. }
  377. } else {
  378. if ($test->has_todo()) {
  379. $status = $self->{'tr_opts'}->{'todo_fail'}->{'id'};
  380. $status_name = 'TODO FAIL';
  381. $test_name =~ s/^(ok|not ok)\s[0-9]*\s//g;
  382. $test_name =~ s/^# todo & skip //gi; #handle todo_skip
  383. $test_name =~ s/# todo\s(.*)$//gi;
  384. $todo_reason = $test->explanation();
  385. }
  386. }
  387. #XXX much of the above code would be unneeded if $test->description wasn't garbage
  388. $test_name =~ s/\s+$//g;
  389. #If this is a TODO, set the reason in the notes
  390. $self->{'tr_opts'}->{'test_notes'} .= "\nTODO reason: $todo_reason\n" if $todo_reason;
  391. my $sr_sys_name = $self->{'tr_opts'}->{'step_results'}->{'name'};
  392. $self->{'tr_opts'}->{'result_custom_options'} = {} if !defined $self->{'tr_opts'}->{'result_custom_options'};
  393. $self->{'tr_opts'}->{'result_custom_options'}->{$sr_sys_name} = [] if !defined $self->{'tr_opts'}->{'result_custom_options'}->{$sr_sys_name};
  394. #TimeStamp every particular step
  395. $line = "[".strftime("%H:%M:%S %b %e %Y",localtime($self->{'lasttime'}))." ($self->{elapse_display})] $line" if $self->{'track_time'};
  396. #XXX Obviously getting the 'expected' and 'actual' from the tap DIAGs would be ideal
  397. push(
  398. @{$self->{'tr_opts'}->{'result_custom_options'}->{$sr_sys_name}},
  399. TestRail::API::buildStepResults($line,"OK",$status_name,$status)
  400. );
  401. print "# Appended step results.\n" if $self->{'tr_opts'}->{'debug'};
  402. return 1;
  403. }
  404. =head2 bailoutCallback
  405. If bail_out is called, note it and add step results.
  406. =cut
  407. sub bailoutCallback {
  408. my ($test) = @_;
  409. my $self = $test->{'parser'};
  410. my $line = $test->as_string;
  411. $self->{'raw_output'} .= "$line\n";
  412. if ($self->{'tr_opts'}->{'step_results'}) {
  413. my $sr_sys_name = $self->{'tr_opts'}->{'step_results'}->{'name'};
  414. #Handle the case where we die right off
  415. $self->{'tr_opts'}->{'result_custom_options'}->{$sr_sys_name} //= [];
  416. push(
  417. @{$self->{'tr_opts'}->{'result_custom_options'}->{$sr_sys_name}},
  418. TestRail::API::buildStepResults("Bail Out!.","Continued testing",$test->explanation,$self->{'tr_opts'}->{'not_ok'}->{'id'})
  419. );
  420. }
  421. $self->{'is_bailout'} = 1;
  422. return;
  423. }
  424. =head2 EOFCallback
  425. If we are running in step_results mode, send over all the step results to TestRail.
  426. Otherwise, upload the overall results of the test to TestRail.
  427. =cut
  428. sub EOFCallback {
  429. my ($self) = @_;
  430. if ( $self->{'track_time'} ) {
  431. #Test done. Record elapsed time.
  432. $self->{'tr_opts'}->{'result_options'}->{'elapsed'} = _compute_elapsed($self->{'starttime'},time());
  433. }
  434. #Fail if the file is not set
  435. if (!defined($self->{'file'})) {
  436. cluck("ERROR: Cannot detect filename, will not be able to find a Test Case with that name");
  437. $self->{'errors'}++;
  438. return 0;
  439. }
  440. my $run_id = $self->{'tr_opts'}->{'run_id'};
  441. my $test_name = basename($self->{'file'});
  442. my $status = $self->{'tr_opts'}->{'ok'}->{'id'};
  443. my $todo_failed = $self->todo() - $self->todo_passed();
  444. $status = $self->{'tr_opts'}->{'not_ok'}->{'id'} if $self->has_problems();
  445. if (!$self->tests_run() && !$self->is_good_plan() && $self->{'tr_opts'}->{test_bad_status}) { #No tests were run, no plan, code is probably bad so allow custom marking
  446. $status = $self->{'tr_opts'}->{$self->{'tr_opts'}->{test_bad_status}}->{'id'};
  447. }
  448. $status = $self->{'tr_opts'}->{'todo_pass'}->{'id'} if $self->todo_passed() && !$self->failed() && $self->is_good_plan(); #If no fails, but a TODO pass, mark as TODOP
  449. $status = $self->{'tr_opts'}->{'todo_fail'}->{'id'} if $todo_failed && !$self->failed() && $self->is_good_plan(); #If no fails, but a TODO fail, prefer TODOF to TODOP
  450. $status = $self->{'tr_opts'}->{'skip'}->{'id'} if $self->skip_all(); #Skip all, whee
  451. #Global status override
  452. $status = $self->{'global_status'} if $self->{'global_status'};
  453. #Notify user about bad plan a bit better, supposing we haven't bailed
  454. if (!$self->is_good_plan() && !$self->{'is_bailout'} && defined $self->tests_run && defined $self->tests_planned ) {
  455. $self->{'raw_output'} .= "\n# ERROR: Bad plan. You ran ".$self->tests_run." tests, but planned ".$self->tests_planned.".";
  456. if ($self->{'tr_opts'}->{'step_results'}) {
  457. my $sr_sys_name = $self->{'tr_opts'}->{'step_results'}->{'name'};
  458. #Handle the case where we die right off
  459. $self->{'tr_opts'}->{'result_custom_options'}->{$sr_sys_name} //= [];
  460. push(
  461. @{$self->{'tr_opts'}->{'result_custom_options'}->{$sr_sys_name}},
  462. TestRail::API::buildStepResults("Bad Plan.",$self->tests_planned." Tests",$self->tests_run." Tests",$status)
  463. );
  464. }
  465. }
  466. #Optional args
  467. my $notes = $self->{'raw_output'};
  468. my $options = $self->{'tr_opts'}->{'result_options'};
  469. my $custom_options = $self->{'tr_opts'}->{'result_custom_options'};
  470. print "# Setting results...\n";
  471. my $cres = $self->_set_result($run_id,$test_name,$status,$notes,$options,$custom_options);
  472. $self->_test_closure();
  473. $self->{'global_status'} = $status;
  474. undef $self->{'tr_opts'} unless $self->{'tr_opts'}->{'debug'};
  475. return $cres;
  476. }
  477. =head2 planCallback
  478. Used to record test planning messages.
  479. =cut
  480. sub planCallback {
  481. my ($plan) = @_;
  482. my $self = $plan->{'parser'};
  483. $self->{raw_output} .= $plan->as_string if $plan->as_string;
  484. }
  485. sub _set_result {
  486. my ($self,$run_id,$test_name,$status,$notes,$options,$custom_options) = @_;
  487. my $tc;
  488. print "# Test elapsed: ".$options->{'elapsed'}."\n" if $options->{'elapsed'};
  489. print "# Attempting to find case by title '".$test_name."' in run $run_id...\n";
  490. $tc = $self->{'tr_opts'}->{'testrail'}->getTestByName($run_id,$test_name);
  491. if (!defined($tc) || (reftype($tc) || 'undef') ne 'HASH') {
  492. cluck("ERROR: Could not find test case: $tc");
  493. $self->{'errors'}++;
  494. return 0;
  495. }
  496. my $xid = $tc ? $tc->{'id'} : '???';
  497. my $cres;
  498. #Set test result
  499. if ($tc) {
  500. print "# Reporting result of case $xid in run $self->{'tr_opts'}->{'run_id'} as status '$status'...";
  501. # createTestResults(test_id,status_id,comment,options,custom_options)
  502. $cres = $self->{'tr_opts'}->{'testrail'}->createTestResults($tc->{'id'},$status, $notes, $options, $custom_options);
  503. print "# OK! (set to $status)\n" if (reftype($cres) || 'undef') eq 'HASH';
  504. }
  505. if (!$tc || ((reftype($cres) || 'undef') ne 'HASH') ) {
  506. print "# Failed!\n";
  507. print "# No Such test case in TestRail ($xid).\n";
  508. $self->{'errors'}++;
  509. }
  510. }
  511. #Compute the expected testrail date interval from 2 unix timestamps.
  512. sub _compute_elapsed {
  513. my ($begin,$end) = @_;
  514. my $secs_elapsed = $end - $begin;
  515. my $mins_elapsed = floor($secs_elapsed / 60);
  516. my $secs_remain = $secs_elapsed % 60;
  517. my $hours_elapsed = floor($mins_elapsed / 60);
  518. my $mins_remain = $mins_elapsed % 60;
  519. my $datestr = "";
  520. #You have bigger problems if your test takes days
  521. if ($hours_elapsed) {
  522. $datestr .= "$hours_elapsed"."h $mins_remain"."m";
  523. } else {
  524. $datestr .= "$mins_elapsed"."m";
  525. }
  526. if ($mins_elapsed) {
  527. $datestr .= " $secs_remain"."s";
  528. } else {
  529. $datestr .= " $secs_elapsed"."s";
  530. }
  531. undef $datestr if $datestr eq "0m 0s";
  532. return $datestr;
  533. }
  534. sub _test_closure {
  535. my ($self) = @_;
  536. return unless $self->{'tr_opts'}->{'autoclose'};
  537. my $is_plan = $self->{'tr_opts'}->{'plan'} ? 1 : 0;
  538. my $id = $self->{'tr_opts'}->{'plan'} ? $self->{'tr_opts'}->{'plan'}->{'id'} : $self->{'tr_opts'}->{'run'};
  539. if ($is_plan) {
  540. my $plan_summary = $self->{'tr_opts'}->{'testrail'}->getPlanSummary($id);
  541. return if ( $plan_summary->{'totals'}->{'Untested'} + $plan_summary->{'totals'}->{'Retest'} );
  542. print "# No more outstanding cases detected. Closing Plan.\n";
  543. $self->{'plan_closed'} = 1;
  544. return $self->{'tr_opts'}->{'testrail'}->closePlan($id);
  545. }
  546. my ($run_summary) = $self->{'tr_opts'}->{'testrail'}->getRunSummary($id);
  547. return if ( $run_summary->{'run_status'}->{'Untested'} + $run_summary->{'run_status'}->{'Retest'} );
  548. print "# No more outstanding cases detected. Closing Run.\n";
  549. $self->{'run_closed'} = 1;
  550. return $self->{'tr_opts'}->{'testrail'}->closeRun($self->{'tr_opts'}->{'run_id'});
  551. }
  552. =head2 make_result
  553. make_result has been overridden to make the parser object available to callbacks.
  554. =cut
  555. sub make_result {
  556. my ($self,@args) = @_;
  557. my $res = $self->SUPER::make_result(@args);
  558. $res->{'parser'} = $self;
  559. return $res;
  560. }
  561. 1;
  562. __END__
  563. =head1 NOTES
  564. When using SKIP: {} (or TODO skip) blocks, you may want to consider naming your skip reasons the same as your test names when running in test_per_ok mode.
  565. =head1 SEE ALSO
  566. L<TestRail::API>
  567. L<TAP::Parser>
  568. =head1 SPECIAL THANKS
  569. Thanks to cPanel Inc, for graciously funding the creation of this module.