Parser.pm 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  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};
  10. use TestRail::API;
  11. use Scalar::Util qw{reftype};
  12. use File::Basename qw{basename};
  13. our $self;
  14. =head1 DESCRIPTION
  15. A TAP parser which will upload your test results to a TestRail install.
  16. Has several options as to how you might want to upload said results.
  17. Subclass of L<TAP::Parser>, see that for usage past the constructor.
  18. You should probably use L<App::Prove::Plugin::TestRail> or the bundled program testrail-report for day-to-day usage...
  19. unless you need to subclass this. In that case a couple of options have been exposed for your convenience.
  20. =cut
  21. =head1 CONSTRUCTOR
  22. =head2 B<new(OPTIONS)>
  23. Get the TAP Parser ready to talk to TestRail, and register a bunch of callbacks to upload test results.
  24. =over 4
  25. =item B<OPTIONS> - HASHREF -- Keys are as follows:
  26. =over 4
  27. =item B<apiurl> - STRING: Full URI to your TestRail installation.
  28. =item B<user> - STRING: Name of your TestRail user.
  29. =item B<pass> - STRING: Said user's password.
  30. =item B<debug> - BOOLEAN: Print a bunch of extra messages
  31. =item B<browser> - OBJECT: Something like an LWP::UserAgent. Useful for mocking with L<Test::LWP::UserAgent::TestRailMock>.
  32. =item B<run> - STRING (optional): name of desired run. Required if run_id not passed.
  33. =item B<run_id> - INTEGER (optional): ID of desired run. Required if run not passed.
  34. =item B<project> - STRING (optional): name of project containing your desired run. Required if project_id not passed.
  35. =item B<project_id> - INTEGER (optional): ID of project containing your desired run. Required if project not passed.
  36. =item B<step_results> - STRING (optional): 'internal name' of the 'step_results' type field available for your project. Mutually exclusive with case_per_ok
  37. =item B<case_per_ok> - BOOLEAN (optional): Consider test files to correspond to section names, and test steps (OKs) to correspond to tests in TestRail. Mutually exclusive with step_results.
  38. =item B<result_options> - HASHREF (optional): Extra options to set with your result. See L<TestRail::API>'s createTestResults function for more information.
  39. =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.
  40. =back
  41. =back
  42. It is worth noting that if neither step_results or case_per_ok is passed, that the test will be passed if it has no problems of any sort, failed otherwise.
  43. In both this mode and step_results, the file name of the test is expected to correspond to the test name in TestRail.
  44. 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.
  45. =cut
  46. sub new {
  47. my ($class,$opts) = @_;
  48. our $self;
  49. #Load our callbacks
  50. $opts->{'callbacks'} = {
  51. 'test' => \&testCallback,
  52. 'comment' => \&commentCallback,
  53. 'unknown' => \&unknownCallback,
  54. 'EOF' => \&EOFCallback
  55. };
  56. my $tropts = {
  57. 'apiurl' => delete $opts->{'apiurl'},
  58. 'user' => delete $opts->{'user'},
  59. 'pass' => delete $opts->{'pass'},
  60. 'debug' => delete $opts->{'debug'},
  61. 'browser' => delete $opts->{'browser'},
  62. 'run' => delete $opts->{'run'},
  63. 'run_id' => delete $opts->{'run_id'},
  64. 'project' => delete $opts->{'project'},
  65. 'project_id' => delete $opts->{'project_id'},
  66. 'step_results' => delete $opts->{'step_results'},
  67. 'case_per_ok' => delete $opts->{'case_per_ok'},
  68. 'plan' => delete $opts->{'plan'},
  69. 'configs' => delete $opts->{'configs'},
  70. #Stubs for extension by subclassers
  71. 'result_options' => delete $opts->{'result_options'},
  72. 'result_custom_options' => delete $opts->{'result_custom_options'}
  73. };
  74. #Allow natural confessing from constructor
  75. my $tr = TestRail::API->new($tropts->{'apiurl'},$tropts->{'user'},$tropts->{'pass'},$tropts->{'debug'});
  76. $tropts->{'testrail'} = $tr;
  77. $tr->{'browser'} = $tropts->{'browser'} if defined($tropts->{'browser'}); #allow mocks
  78. $tr->{'debug'} = 0; #Always suppress in production
  79. #Get project ID from name, if not provided
  80. if (!defined($tropts->{'project_id'})) {
  81. my $pname = $tropts->{'project'};
  82. $tropts->{'project'} = $tr->getProjectByName($pname);
  83. confess("Could not list projects! Shutting down.") if ($tropts->{'project'} == -500);
  84. if (!$tropts->{'project'}) {
  85. confess("No project (or project_id) provided, or that which was provided was invalid!");
  86. }
  87. } else {
  88. $tropts->{'project'} = $tr->getProjectByID($tropts->{'project_id'});
  89. confess("No such project with ID $tropts->{project_id}!") if !$tropts->{'project'};
  90. }
  91. $tropts->{'project_id'} = $tropts->{'project'}->{'id'};
  92. #Discover possible test statuses
  93. $tropts->{'statuses'} = $tr->getPossibleTestStatuses();
  94. my @ok = grep {$_->{'name'} eq 'passed'} @{$tropts->{'statuses'}};
  95. my @not_ok = grep {$_->{'name'} eq 'failed'} @{$tropts->{'statuses'}};
  96. my @skip = grep {$_->{'name'} eq 'skip'} @{$tropts->{'statuses'}};
  97. my @todof = grep {$_->{'name'} eq 'todo_fail'} @{$tropts->{'statuses'}};
  98. my @todop = grep {$_->{'name'} eq 'todo_pass'} @{$tropts->{'statuses'}};
  99. confess("No status with internal name 'passed' in TestRail!") unless scalar(@ok);
  100. confess("No status with internal name 'failed' in TestRail!") unless scalar(@not_ok);
  101. confess("No status with internal name 'skip' in TestRail!") unless scalar(@skip);
  102. confess("No status with internal name 'todo_fail' in TestRail!") unless scalar(@todof);
  103. confess("No status with internal name 'todo_pass' in TestRail!") unless scalar(@todop);
  104. $tropts->{'ok'} = $ok[0];
  105. $tropts->{'not_ok'} = $not_ok[0];
  106. $tropts->{'skip'} = $skip[0];
  107. $tropts->{'todo_fail'} = $todof[0];
  108. $tropts->{'todo_pass'} = $todop[0];
  109. #Grab run
  110. my $run_id = $tropts->{'run_id'};
  111. my $run;
  112. #TODO check if configs passed are defined for project
  113. if ($tropts->{'run'}) {
  114. if ($tropts->{'plan'}) {
  115. #Attempt to find run, filtered by configurations
  116. my $plan = $tr->getPlanByName($tropts->{'project_id'},$tropts->{'plan'});
  117. if ($plan) {
  118. $tropts->{'plan'} = $plan; #XXX Save for later just in case?
  119. $run = $tr->getChildRunByName($plan,$tropts->{'run'},$tropts->{'configs'}); #Find plan filtered by configs
  120. if (defined($run) && (reftype($run) || 'undef') eq 'HASH') {
  121. $tropts->{'run'} = $run;
  122. $tropts->{'run_id'} = $run->{'id'};
  123. }
  124. } else {
  125. confess("Could not find plan ".$tropts->{'plan'}." in provided project!");
  126. }
  127. } else {
  128. $run = $tr->getRunByName($tropts->{'project_id'},$tropts->{'run'});
  129. if (defined($run) && (reftype($run) || 'undef') eq 'HASH') {
  130. $tropts->{'run'} = $run;
  131. $tropts->{'run_id'} = $run->{'id'};
  132. }
  133. }
  134. } else {
  135. $tropts->{'run'} = $tr->getRunByID($run_id);
  136. }
  137. confess("No run ID provided, and no run with specified name exists in provided project/plan!") if !$tropts->{'run_id'};
  138. $self = $class->SUPER::new($opts);
  139. if (defined($self->{'_iterator'}->{'command'}) && reftype($self->{'_iterator'}->{'command'}) eq 'ARRAY' ) {
  140. $self->{'file'} = $self->{'_iterator'}->{'command'}->[-1];
  141. print "PROCESSING RESULTS FROM TEST FILE: $self->{'file'}\n";
  142. $self->{'track_time'} = 1;
  143. } else {
  144. #Not running inside of prove in real-time, don't bother with tracking elapsed times.
  145. $self->{'track_time'} = 0;
  146. }
  147. #Make sure the step results field passed exists on the system
  148. $tropts->{'step_results'} = $tr->getTestResultFieldByName($tropts->{'step_results'},$tropts->{'project_id'}) if defined $tropts->{'step_results'};
  149. $self->{'tr_opts'} = $tropts;
  150. $self->{'errors'} = 0;
  151. #Start the shot clock
  152. $self->{'starttime'} = time();
  153. return $self;
  154. }
  155. =head1 PARSER CALLBACKS
  156. =head2 unknownCallback
  157. Called whenever we encounter an unknown line in TAP. Only useful for prove output, as we might pick a filename out of there.
  158. Stores said filename for future use if encountered.
  159. =cut
  160. # Look for file boundaries, etc.
  161. sub unknownCallback {
  162. my (@args) = @_;
  163. our $self;
  164. my $line = $args[0]->as_string;
  165. #try to pick out the filename if we are running this on TAP in files
  166. #old prove
  167. if ($line =~ /^Running\s(.*)/) {
  168. #TODO figure out which testsuite this implies
  169. $self->{'file'} = $1;
  170. print "PROCESSING RESULTS FROM TEST FILE: $self->{'file'}\n";
  171. }
  172. #RAW tap #XXX this regex could be improved
  173. if ($line =~ /(.*)\s\.\.\s*$/) {
  174. $self->{'file'} = $1 unless $line =~ /^[ok|not ok] - /; #a little more careful
  175. }
  176. print "$line\n" if ($line =~ /^error/i);
  177. }
  178. =head2 commentCallback
  179. Grabs comments preceding a test so that we can include that as the test's notes.
  180. Especially useful when merge=1 is passed to the constructor.
  181. =cut
  182. # Register the current suite or test desc for use by test callback, if the line begins with the special magic words
  183. sub commentCallback {
  184. my (@args) = @_;
  185. our $self;
  186. my $line = $args[0]->as_string;
  187. if ($line =~ m/^#TESTDESC:\s*/) {
  188. $self->{'tr_opts'}->{'test_desc'} = $line;
  189. $self->{'tr_opts'}->{'test_desc'} =~ s/^#TESTDESC:\s*//g;
  190. return;
  191. }
  192. #keep all comments before a test that aren't these special directives to save in NOTES field of reportTCResult
  193. $self->{'tr_opts'}->{'test_notes'} .= "$line\n";
  194. }
  195. =head2 testCallback
  196. If we are using step_results, append it to the step results array for use at EOF.
  197. If we are using case_per_ok, update TestRail per case.
  198. Otherwise, do nothing.
  199. =cut
  200. sub testCallback {
  201. my (@args) = @_;
  202. my $test = $args[0];
  203. our $self;
  204. if ( $self->{'track_time'} ) {
  205. #Test done. Record elapsed time.
  206. $self->{'tr_opts'}->{'result_options'}->{'elapsed'} = _compute_elapsed($self->{'starttime'},time());
  207. }
  208. #Don't do anything if we don't want to map TR case => ok or use step-by-step results
  209. if ( !($self->{'tr_opts'}->{'step_results'} || $self->{'tr_opts'}->{'case_per_ok'}) ) {
  210. print "Neither step_results of case_per_ok set. No action to be taken, except on a whole test basis.\n" if $self->{'tr_opts'}->{'debug'};
  211. return 1;
  212. }
  213. if ($self->{'tr_opts'}->{'step_results'} && $self->{'tr_opts'}->{'case_per_ok'}) {
  214. cluck("ERROR: step_options and case_per_ok options are mutually exclusive!");
  215. $self->{'errors'}++;
  216. return 0;
  217. }
  218. #Fail on unplanned tests
  219. if ($test->is_unplanned()) {
  220. cluck("ERROR: Unplanned test detected. Will not attempt to upload results.");
  221. $self->{'errors'}++;
  222. return 0;
  223. }
  224. #Default assumption is that case name is step text (case_per_ok), unless...
  225. my $line = $test->as_string;
  226. $line =~ s/^(ok|not ok)\s[0-9]*\s-\s//g;
  227. my $test_name = $line;
  228. my $run_id = $self->{'tr_opts'}->{'run_id'};
  229. print "Assuming test name is '$test_name'...\n" if $self->{'tr_opts'}->{'debug'} && !$self->{'tr_opts'}->{'step_results'};
  230. my $todo_reason;
  231. #Setup args to pass to function
  232. my $status = $self->{'tr_opts'}->{'not_ok'}->{'id'};
  233. if ($test->is_actual_ok()) {
  234. $status = $self->{'tr_opts'}->{'ok'}->{'id'};
  235. if ($test->has_skip()) {
  236. $status = $self->{'tr_opts'}->{'skip'}->{'id'};
  237. $test_name =~ s/^(ok|not ok)\s[0-9]*\s//g;
  238. $test_name =~ s/^# skip //gi;
  239. }
  240. if ($test->has_todo()) {
  241. $status = $self->{'tr_opts'}->{'todo_pass'}->{'id'};
  242. $test_name =~ s/^(ok|not ok)\s[0-9]*\s//g;
  243. $test_name =~ s/(^# todo & skip )//gi; #handle todo_skip
  244. $test_name =~ s/ # todo\s(.*)$//gi;
  245. $todo_reason = $1;
  246. }
  247. } else {
  248. if ($test->has_todo()) {
  249. $status = $self->{'tr_opts'}->{'todo_pass'}->{'id'};
  250. $test_name =~ s/^(ok|not ok)\s[0-9]*\s//g;
  251. $test_name =~ s/^# todo & skip //gi; #handle todo_skip
  252. $test_name =~ s/# todo\s(.*)$//gi;
  253. $todo_reason = $1;
  254. }
  255. }
  256. #If this is a TODO, set the reason in the notes
  257. $self->{'tr_opts'}->{'test_notes'} .= "\nTODO reason: $todo_reason\n" if $todo_reason;
  258. #Setup step options and exit if that's the mode we be rollin'
  259. if ($self->{'tr_opts'}->{'step_results'}) {
  260. $self->{'tr_opts'}->{'result_custom_options'} = {} if !defined $self->{'tr_opts'}->{'result_custom_options'};
  261. $self->{'tr_opts'}->{'result_custom_options'}->{'step_results'} = [] if !defined $self->{'tr_opts'}->{'result_custom_options'}->{'step_results'};
  262. #XXX Obviously getting the 'expected' and 'actual' from the tap DIAGs would be ideal
  263. push(
  264. @{$self->{'tr_opts'}->{'result_custom_options'}->{'step_results'}},
  265. TestRail::API::buildStepResults($line,"Good result","Bad Result",$status)
  266. );
  267. print "Appended step results.\n" if $self->{'tr_opts'}->{'debug'};
  268. return 1;
  269. }
  270. #Optional args
  271. my $notes = $self->{'tr_opts'}->{'test_notes'};
  272. my $options = $self->{'tr_opts'}->{'result_options'};
  273. my $custom_options = $self->{'tr_opts'}->{'result_custom_options'};
  274. _set_result($run_id,$test_name,$status,$notes,$options,$custom_options);
  275. #Re-start the shot clock
  276. $self->{'starttime'} = time();
  277. #Blank out test description in anticipation of next test
  278. # also blank out notes
  279. $self->{'tr_opts'}->{'test_notes'} = undef;
  280. $self->{'tr_opts'}->{'test_desc'} = undef;
  281. }
  282. =head2 EOFCallback
  283. If we are running in step_results mode, send over all the step results to TestRail.
  284. If we are running in case_per_ok mode, do nothing.
  285. Otherwise, upload the overall results of the test to TestRail.
  286. =cut
  287. sub EOFCallback {
  288. our $self;
  289. if ( $self->{'track_time'} ) {
  290. #Test done. Record elapsed time.
  291. $self->{'tr_opts'}->{'result_options'}->{'elapsed'} = _compute_elapsed($self->{'starttime'},time());
  292. }
  293. if (!(!$self->{'tr_opts'}->{'step_results'} xor $self->{'tr_opts'}->{'case_per_ok'})) {
  294. print "Nothing left to do.\n";
  295. undef $self->{'tr_opts'} unless $self->{'tr_opts'}->{'debug'};
  296. return 1;
  297. }
  298. #Fail if the file is not set
  299. if (!defined($self->{'file'})) {
  300. cluck("ERROR: Cannot detect filename, will not be able to find a Test Case with that name");
  301. $self->{'errors'}++;
  302. return 0;
  303. }
  304. my $run_id = $self->{'tr_opts'}->{'run_id'};
  305. my $test_name = basename($self->{'file'});
  306. my $status = $self->{'tr_opts'}->{'ok'}->{'id'};
  307. $status = $self->{'tr_opts'}->{'not_ok'}->{'id'} if $self->has_problems();
  308. $status = $self->{'tr_opts'}->{'skip'}->{'id'} if $self->skip_all();
  309. #Optional args
  310. my $notes = $self->{'tr_opts'}->{'test_notes'};
  311. my $options = $self->{'tr_opts'}->{'result_options'};
  312. my $custom_options = $self->{'tr_opts'}->{'result_custom_options'};
  313. print "Setting results...\n";
  314. my $cres = _set_result($run_id,$test_name,$status,$notes,$options,$custom_options);
  315. undef $self->{'tr_opts'} unless $self->{'tr_opts'}->{'debug'};
  316. return $cres;
  317. }
  318. sub _set_result {
  319. my ($run_id,$test_name,$status,$notes,$options,$custom_options) = @_;
  320. our $self;
  321. my $tc;
  322. print "Test elapsed: ".$options->{'elapsed'}."\n" if $options->{'elapsed'};
  323. print "Attempting to find case by title '".$test_name."'...\n";
  324. $tc = $self->{'tr_opts'}->{'testrail'}->getTestByName($run_id,$test_name);
  325. if (!defined($tc) || (reftype($tc) || 'undef') ne 'HASH') {
  326. cluck("ERROR: Could not find test case: $tc");
  327. $self->{'errors'}++;
  328. return 0;
  329. }
  330. my $xid = $tc ? $tc->{'id'} : '???';
  331. my $cres;
  332. #Set test result
  333. if ($tc) {
  334. print "Reporting result of case $xid in run $self->{'tr_opts'}->{'run_id'} as status '$status'...";
  335. # createTestResults(test_id,status_id,comment,options,custom_options)
  336. $cres = $self->{'tr_opts'}->{'testrail'}->createTestResults($tc->{'id'},$status, $notes, $options, $custom_options);
  337. print "OK! (set to $status)\n" if (reftype($cres) || 'undef') eq 'HASH';
  338. }
  339. if (!$tc || ((reftype($cres) || 'undef') ne 'HASH') ) {
  340. print "Failed!\n";
  341. print "No Such test case in TestRail ($xid).\n";
  342. $self->{'errors'}++;
  343. }
  344. }
  345. #Compute the expected testrail date interval from 2 unix timestamps.
  346. sub _compute_elapsed {
  347. my ($begin,$end) = @_;
  348. my $secs_elapsed = $end - $begin;
  349. my $mins_elapsed = floor($secs_elapsed / 60);
  350. my $secs_remain = $secs_elapsed % 60;
  351. my $hours_elapsed = floor($mins_elapsed / 60);
  352. my $mins_remain = $mins_elapsed % 60;
  353. my $datestr = "";
  354. #You have bigger problems if your test takes days
  355. if ($hours_elapsed) {
  356. $datestr .= "$hours_elapsed"."h $mins_remain"."m";
  357. } else {
  358. $datestr .= "$mins_elapsed"."m";
  359. }
  360. if ($mins_elapsed) {
  361. $datestr .= " $secs_remain"."s";
  362. } else {
  363. $datestr .= " $secs_elapsed"."s";
  364. }
  365. undef $datestr if $datestr eq "0m 0s";
  366. return $datestr;
  367. }
  368. 1;
  369. __END__
  370. =head1 NOTES
  371. 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.
  372. =head1 SEE ALSO
  373. L<TestRail::API>
  374. L<TAP::Parser>
  375. =head1 SPECIAL THANKS
  376. Thanks to cPanel Inc, for graciously funding the creation of this module.