Bladeren bron

Add all the Prove Plugin goodies, still need to get dzil release working

George S. Baugh 11 jaren geleden
bovenliggende
commit
367dd11b0c
11 gewijzigde bestanden met toevoegingen van 1056 en 14 verwijderingen
  1. 3 0
      Changes
  2. 197 0
      bin/testrail-report
  3. 99 0
      lib/App/Prove/Plugin/TestRail.pm
  4. 152 11
      lib/Test/LWP/UserAgent/TestRailMock.pm
  5. 61 0
      lib/Test/Rail/Harness.pm
  6. 358 0
      lib/Test/Rail/Parser.pm
  7. 34 2
      lib/TestRail/API.pm
  8. 131 0
      t/Test-Rail-Parser.t
  9. 0 1
      t/TestRail-API.t
  10. 10 0
      t/fake.test
  11. 11 0
      t/faker.test

+ 3 - 0
Changes

@@ -9,6 +9,9 @@ Revision history for Perl module TestRail::API
     - Add fatal type checking of inputs for all methods, add test to make sure exceptions thrown correctly
     - Add offset argument to getTestResults call.
     - Add (auto-generated) mock class so we don't have to skip practically all of TestRail-API.t
+    - Add getTestResultFieldByName method and relevant tests
+    - Add Test::Rail::Parser and App::Prove::Plugin::TestRail so you can upload results
+    - Add testrail-report binary for those who want to run on static TAP rather than 'do it live' with prove -P
 
 0.011 2014-12-04 TEODESIAN
     - Converted to using dzil, and testing using TestingMania

+ 197 - 0
bin/testrail-report

@@ -0,0 +1,197 @@
+#! /usr/bin/env perl
+# ABSTRACT: Upload your TAP results to TestRail after they've finished
+# PODNAME: testrail-report
+
+use strict;
+use warnings;
+
+use Getopt::Long;
+use Term::ANSIColor qw(colorstrip);
+use Test::Rail::Parser;
+
+print "testrail-report\n----------------------\n";
+
+sub help {
+    print "testrail-report - report raw TAP results to a TestRail install
+
+USAGE:
+  testrail-report [OPTIONS] tapfile
+  prove -v sometest.t > results.tap && testrail-report [OPTIONS] \\
+  results.tap
+
+  prove -v sometest.t | testrail-report [OPTIONS]
+
+  prove -PTestRail='http://some.testlink.install/','someUser',\\
+  'somePassword' sometest.t
+
+PARAMETERS:
+  [MANDATORY PARAMETERS]
+  --project [someproject] : associate results (if any) with the
+                            provided project name.
+
+  --run [somerun] : associates results (if any) with the provided run
+                    name.
+
+  IF none of these options are provided, you will be asked to type
+  these in as needed, supposing you are not redirecting input
+  (such as piping into this command).
+
+  [OPTIONS]
+
+  [CONFIG OVERRIDES]
+  In your \$HOME, put a file called .testrailrc with key=value
+  syntax separated by newlines.  Valid Keys are: apiurl,user,password
+
+  [CONFIG OPTIONS] - These override the config, if present.
+                     If neither are used, you will be prompted.
+
+  --apiurl   [url] : full URL to get to TestRail index document
+  --password [key] : Your TestRail Password.
+  --user    [name] : Your TestRail User Name.
+
+  [BEHAVIOR]
+  --case-ok      : Whether to consider each OK to correspond to
+                   a test in TestRail
+
+  --step-results [name] : 'System Name' of a 'step_results' type field
+                    to set for your tests.
+
+  These options are mutually exclusive.  If neither is set, the
+  overall result of the test will be used as the pass/fail for the test.
+
+PROVE PLUGIN:
+
+  passing -PTestRail=apiurl,user,pass,project,run to prove will
+  automatically upload your test results while the test is running if
+  real-time results are desired.
+
+REQUIREMENTS:
+  Your TestRail install must have 3 custom statuses with the internal
+  names 'skip', 'todo_pass', and 'todo_fail', to represent those
+  states which TAP can have.
+
+";
+    exit 0;
+}
+
+sub userInput {
+ $| = 1;
+ my $rt = <STDIN>;
+ chomp $rt;
+ return $rt;
+}
+
+sub parseConfig {
+    my $results = {};
+    my $arr =[];
+
+    open(my $fh, '<', $ENV{"HOME"} . '/.testrailrc') or return (undef,undef,undef);#couldn't open!
+    while (<$fh>) {
+        chomp;
+        @$arr = split(/=/,$_);
+        if (scalar(@$arr) != 2) {
+            warn("Could not parse $_ in tlreport config\n");
+            next;
+        }
+        $results->{lc($arr->[0])} = $arr->[1];
+    }
+    close($fh);
+    return ($results->{'apiurl'},$results->{'password'},$results->{'user'});
+}
+
+#Main loop------------
+
+my ($help,$apiurl,$user,$password,$project,$run,$case_per_ok,$step_results);
+
+#parse switches
+GetOptions(
+    'run=s'          => \$run,
+    'apiurl=s'       => \$apiurl,
+    'password=s'     => \$password,
+    'user=s'         => \$user,
+    'project=s'      => \$project,
+    'case-ok'        => \$case_per_ok,
+    'step-results=s' => \$step_results,
+    'help'           => \$help
+);
+
+if ($help) { help(); }
+
+#Parse config file if we are missing api url/key or user
+if (-e $ENV{"HOME"} . '/.testrailrc' && (!$apiurl || !$password || !$user) ) {
+    ($apiurl,$password,$user) = parseConfig();
+}
+
+#If argument is passed use it instead of stdin
+my $file = $ARGV[0];
+die "No Such File $file" if ($file && !-e $file);
+my ($fh,$fcontents);
+if ($file) {
+    open($fh,'<',$file);
+    while (<$fh>) {
+        $_ = colorstrip($_); #strip prove brain damage
+        s/^\s*//g; #Fix more brain damage
+        $fcontents .= $_;
+    }
+    close($fh);
+} else {
+    #Just read STDIN, print help if no file was passed
+    if (-t STDIN) { help(); }
+    if ( !$run || !$apiurl || !$password || !$user || !$project ) { print "ERROR: Interactive mode not allowed when piping input.  See --help for options.\n"; exit 0;};
+    while (<>) {
+        $_ = colorstrip($_); #strip prove brain damage
+        s/^\s*//g; #Fix prove brain damage
+        $fcontents .= $_;
+    }
+}
+
+#Interrogate user if they didn't provide info
+if (!$apiurl) {
+    print "Type the API endpoint url for your testLink install below:\n";
+    $apiurl = userInput();
+}
+
+if (!$user) {
+    print "Type your testLink user name below:\n";
+    $user = userInput();
+}
+
+if (!$password) {
+    print "Type the password for your testLink user below:\n";
+    $password = userInput();
+}
+
+if (!$apiurl || !$password || !$user) {
+    print "ERROR: api url, username and password cannot be blank.\n";
+    exit 1;
+}
+
+#Interrogate user if they didn't provide info
+if (!$project) {
+    print "Type the name of the project you are testing under:\n";
+    $project = userInput();
+}
+
+# Interrogate user if options were not passed
+if (!$run) {
+    print "Type the name of the existing run you would like to run against:\n";
+    $run = userInput();
+}
+
+my $tap = Test::Rail::Parser->new({
+    'tap'                 => $fcontents,
+    'apiurl'              => $apiurl,
+    'user'                => $user,
+    'pass'                => $password,
+    'run'                 => $run,
+    'project'             => $project,
+    'case_per_ok'         => $case_per_ok,
+    'step_results'        => $step_results,
+    'merge'               => 1
+});
+$tap->run();
+
+print "Done.\n";
+
+#all done
+0;

+ 99 - 0
lib/App/Prove/Plugin/TestRail.pm

@@ -0,0 +1,99 @@
+# ABSTRACT: Upload your TAP results to TestRail in realtime
+# PODNAME: App::Prove::Plugin::TestRail
+
+package App::Prove::Plugin::TestRail;
+
+use strict;
+use warnings;
+use utf8;
+
+=head1 SYNOPSIS
+
+`prove -PTestRail='http://some.testlink.install/,someUser,somePassword,TestProject,TestRun' sometest.t`
+
+=cut
+
+=head1 DESCRIPTION
+
+Prove plugin to upload test results to TestRail installations.
+
+Accepts input in the standard Prove plugin fashion (-Ppluginname=value,value,value...), but will also parse a config file.
+
+If ~/.testrailrc exists, it will be parsed for any of these values in a newline separated key=value list.  Example:
+
+    apiurl=http://some.testrail.install
+    user=someGuy
+    password=superS3cret
+    project=TestProject
+    run=TestRun
+    case_per_ok=0
+    step_results=sr_sys_name
+
+Be aware that if you do so, it will look for any unsatisfied arguments in the order of their appearance above.
+
+=cut
+
+sub load {
+    my ($class, $p) = @_;
+
+    my ($apiurl,$password,$user,$project,$run,$case_per_ok,$step_results) = _parseConfig();
+
+    my $app = $p->{app_prove};
+    my $args = $p->{'args'};
+
+    $apiurl   //= shift @$args;
+    $user     //= shift @$args;
+    $password //= shift @$args;
+    $project  //= shift @$args;
+    $run      //= shift @$args;
+
+    $case_per_ok  //= shift @$args;
+    $step_results //= shift @$args;
+
+    $app->harness('Test::Rail::Harness');
+    $app->merge(1);
+
+    #XXX I can't figure out for the life of me any other way to pass this data. #YOLO
+    $ENV{'TESTRAIL_APIURL'} = $apiurl;
+    $ENV{'TESTRAIL_USER'}   = $user;
+    $ENV{'TESTRAIL_PASS'}   = $password;
+    $ENV{'TESTRAIL_PROJ'}   = $project;
+    $ENV{'TESTRAIL_RUN'}    = $run;
+    $ENV{'TESTRAIL_CASEOK'} = $case_per_ok;
+    $ENV{'TESTRAIL_STEPS'}  = $step_results;
+
+}
+
+sub _parseConfig {
+    my $results = {};
+    my $arr =[];
+
+    open(my $fh, '<', $ENV{"HOME"} . '/.testrailrc') or return (undef,undef,undef);#couldn't open!
+    while (<$fh>) {
+        chomp;
+        @$arr = split(/=/,$_);
+        if (scalar(@$arr) != 2) {
+            warn("Could not parse $_ in tlreport config\n");
+            next;
+        }
+        $results->{lc($arr->[0])} = $arr->[1];
+    }
+    close($fh);
+    return ($results->{'apiurl'},$results->{'password'},$results->{'user'},$results->{'project'},$results->{'run'},$results->{'case_per_ok'},$results->{'step_results'});
+}
+
+1;
+
+__END__
+
+=head1 SEE ALSO
+
+L<TestRail::API>
+
+L<Test::Rail::Parser>
+
+L<App::Prove>
+
+=head1 SPECIAL THANKS
+
+Thanks to cPanel Inc, for graciously funding the creation of this module.

+ 152 - 11
lib/Test/LWP/UserAgent/TestRailMock.pm

@@ -168,7 +168,7 @@ $VAR4 = bless( {
                  'content-type' => 'application/json; charset=utf-8',
                  'server' => 'Apache/2.4.7 (Ubuntu)'
                }, 'HTTP::Headers' );
-$VAR5 = '[{"id":9,"name":"CRUSH ALL HUMANS","announcement":"Robo-Signed Soviet 5 Year Project","show_announcement":false,"is_completed":false,"completed_on":null,"suite_mode":3,"url":"http:\\/\\/testrail.local\\/\\/index.php?\\/projects\\/overview\\/9"}]';
+$VAR5 = '[{"id":9,"name":"CRUSH ALL HUMANS","announcement":"Robo-Signed Soviet 5 Year Project","show_announcement":false,"is_completed":false,"completed_on":null,"suite_mode":3,"url":"http:\\/\\/testrail.local\\/\\/index.php?\\/projects\\/overview\\/9"},{"id":10,"name":"TestProject","is_completed":false}]';
 $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
 
 }
@@ -330,14 +330,14 @@ $VAR4 = bless( {
                  'content-type' => 'application/json; charset=utf-8',
                  'server' => 'Apache/2.4.7 (Ubuntu)'
                }, 'HTTP::Headers' );
-$VAR5 = '[{"id":9,"suite_id":9,"name":"CARBON LIQUEFACTION","description":null,"parent_id":null,"display_order":1,"depth":0}]';
+$VAR5 = '[{"id":9,"suite_id":9,"name":"CARBON LIQUEFACTION","description":null,"parent_id":null,"display_order":1,"depth":0},{"id":10,"suite_id":9,"name":"fake.test","description":"Fake as it gets","parent_id":null}]';
 $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
 
 }
 
 {
 
-$VAR1 = 'index.php?/api/v2/get_sections/9&suite_id=9';
+$VAR1 = 'index.php?/api/v2/get_sections/10&suite_id=9';
 $VAR2 = '200';
 $VAR3 = 'OK';
 $VAR4 = bless( {
@@ -357,11 +357,12 @@ $VAR4 = bless( {
                  'content-type' => 'application/json; charset=utf-8',
                  'server' => 'Apache/2.4.7 (Ubuntu)'
                }, 'HTTP::Headers' );
-$VAR5 = '[{"id":9,"suite_id":9,"name":"CARBON LIQUEFACTION","description":null,"parent_id":null,"display_order":1,"depth":0}]';
+$VAR5 = '[{"id":9,"suite_id":9,"name":"CARBON LIQUEFACTION","description":null,"parent_id":null,"display_order":1,"depth":0},{"id":10,"suite_id":9,"name":"fake.test","description":"Fake as it gets","parent_id":null}]';
 $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
 
 }
 
+
 {
 
 $VAR1 = 'index.php?/api/v2/get_section/9';
@@ -445,7 +446,7 @@ $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4,
 
 {
 
-$VAR1 = 'index.php?/api/v2/get_cases/9&suite_id=9&section_id=9';
+$VAR1 = 'index.php?/api/v2/get_cases/9&suite_id=9&section_id=10';
 $VAR2 = '200';
 $VAR3 = 'OK';
 $VAR4 = bless( {
@@ -465,7 +466,34 @@ $VAR4 = bless( {
                  'content-type' => 'application/json; charset=utf-8',
                  'server' => 'Apache/2.4.7 (Ubuntu)'
                }, 'HTTP::Headers' );
-$VAR5 = '[{"id":8,"title":"STROGGIFY POPULATION CENTERS","section_id":9,"type_id":6,"priority_id":4,"milestone_id":null,"refs":null,"created_by":1,"created_on":1419364929,"updated_by":1,"updated_on":1419364929,"estimate":null,"estimate_forecast":null,"suite_id":9,"custom_preconds":null,"custom_steps":null,"custom_expected":null}]';
+$VAR5 = '[{"id":10,"title":"STORAGE TANKS SEARED","section_id":10,"type_id":6,"priority_id":4,"milestone_id":null,"refs":null,"created_by":1,"created_on":1419364929,"updated_by":1,"updated_on":1419364929,"estimate":null,"estimate_forecast":null,"suite_id":9,"custom_preconds":null,"custom_steps":null,"custom_expected":null},{"id":11,"title":"NOT SO SEARED AFTER ARR","section_id":10,"type_id":6,"priority_id":4,"milestone_id":null,"refs":null,"created_by":1,"created_on":1419364929,"updated_by":1,"updated_on":1419364929,"estimate":null,"estimate_forecast":null,"suite_id":9,"custom_preconds":null,"custom_steps":null,"custom_expected":null}]';
+$mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
+
+}
+
+{
+
+$VAR1 = 'index.php?/api/v2/get_cases/10&suite_id=9&section_id=10';
+$VAR2 = '200';
+$VAR3 = 'OK';
+$VAR4 = bless( {
+                 'connection' => 'close',
+                 'x-powered-by' => 'PHP/5.5.9-1ubuntu4.5',
+                 'client-response-num' => 1,
+                 'date' => 'Tue, 23 Dec 2014 20:02:09 GMT',
+                 'client-peer' => '192.168.122.217:80',
+                 'content-length' => '322',
+                 '::std_case' => {
+                                   'client-date' => 'Client-Date',
+                                   'x-powered-by' => 'X-Powered-By',
+                                   'client-response-num' => 'Client-Response-Num',
+                                   'client-peer' => 'Client-Peer'
+                                 },
+                 'client-date' => 'Tue, 23 Dec 2014 20:02:09 GMT',
+                 'content-type' => 'application/json; charset=utf-8',
+                 'server' => 'Apache/2.4.7 (Ubuntu)'
+               }, 'HTTP::Headers' );
+$VAR5 = '[{"id":10,"title":"STORAGE TANKS SEARED","section_id":10,"type_id":6,"priority_id":4,"milestone_id":null,"refs":null,"created_by":1,"created_on":1419364929,"updated_by":1,"updated_on":1419364929,"estimate":null,"estimate_forecast":null,"suite_id":9,"custom_preconds":null,"custom_steps":null,"custom_expected":null},{"id":11,"title":"NOT SO SEARED AFTER ARR","section_id":10,"type_id":6,"priority_id":4,"milestone_id":null,"refs":null,"created_by":1,"created_on":1419364929,"updated_by":1,"updated_on":1419364929,"estimate":null,"estimate_forecast":null,"suite_id":9,"custom_preconds":null,"custom_steps":null,"custom_expected":null}]';
 $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
 
 }
@@ -553,7 +581,7 @@ $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4,
 
 {
 
-$VAR1 = 'index.php?/api/v2/get_runs/9';
+$VAR1 = 'index.php?/api/v2/get_runs/10';
 $VAR2 = '200';
 $VAR3 = 'OK';
 $VAR4 = bless( {
@@ -573,7 +601,10 @@ $VAR4 = bless( {
                  'content-type' => 'application/json; charset=utf-8',
                  'server' => 'Apache/2.4.7 (Ubuntu)'
                }, 'HTTP::Headers' );
-$VAR5 = '[{"id":22,"suite_id":9,"name":"SEND T-1000 INFILTRATION UNITS BACK IN TIME","description":"ACQUIRE CLOTHES, BOOTS AND MOTORCYCLE","milestone_id":null,"assignedto_id":null,"include_all":true,"is_completed":false,"completed_on":null,"config":null,"config_ids":[],"passed_count":0,"blocked_count":0,"untested_count":1,"retest_count":0,"failed_count":0,"custom_status1_count":0,"custom_status2_count":0,"custom_status3_count":0,"custom_status4_count":0,"custom_status5_count":0,"custom_status6_count":0,"custom_status7_count":0,"project_id":9,"plan_id":null,"created_on":1419364929,"created_by":1,"url":"http:\\/\\/testrail.local\\/\\/index.php?\\/runs\\/view\\/22"}]';
+$VAR5 = '[
+    {"id":1,"suite_id":9,"name":"TestingSuite","description":"ACQUIRE CLOTHES, BOOTS AND MOTORCYCLE","milestone_id":null,"assignedto_id":null,"include_all":true,"is_completed":false,"completed_on":null,"config":null,"config_ids":[],"passed_count":0,"blocked_count":0,"untested_count":1,"retest_count":0,"failed_count":0,"custom_status1_count":0,"custom_status2_count":0,"custom_status3_count":0,"custom_status4_count":0,"custom_status5_count":0,"custom_status6_count":0,"custom_status7_count":0,"project_id":9,"plan_id":null,"created_on":1419364929,"created_by":1,"url":"http:\\/\\/testrail.local\\/\\/index.php?\\/runs\\/view\\/22"},
+    {"id":2,"suite_id":9,"name":"OtherOtherSuite","description":"bah","completed_on":null}
+]';
 $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
 
 }
@@ -605,6 +636,34 @@ $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4,
 
 }
 
+{
+
+$VAR1 = 'index.php?/api/v2/get_run/1';
+$VAR2 = '200';
+$VAR3 = 'OK';
+$VAR4 = bless( {
+                 'connection' => 'close',
+                 'x-powered-by' => 'PHP/5.5.9-1ubuntu4.5',
+                 'client-response-num' => 1,
+                 'date' => 'Tue, 23 Dec 2014 20:02:10 GMT',
+                 'client-peer' => '192.168.122.217:80',
+                 'content-length' => '654',
+                 '::std_case' => {
+                                   'client-date' => 'Client-Date',
+                                   'x-powered-by' => 'X-Powered-By',
+                                   'client-response-num' => 'Client-Response-Num',
+                                   'client-peer' => 'Client-Peer'
+                                 },
+                 'client-date' => 'Tue, 23 Dec 2014 20:02:10 GMT',
+                 'content-type' => 'application/json; charset=utf-8',
+                 'server' => 'Apache/2.4.7 (Ubuntu)'
+               }, 'HTTP::Headers' );
+$VAR5 = '{"id":22,"suite_id":9,"name":"SEND T-1000 INFILTRATION UNITS BACK IN TIME","description":"ACQUIRE CLOTHES, BOOTS AND MOTORCYCLE","milestone_id":null,"assignedto_id":null,"include_all":true,"is_completed":false,"completed_on":null,"config":null,"config_ids":[],"passed_count":0,"blocked_count":0,"untested_count":1,"retest_count":0,"failed_count":0,"custom_status1_count":0,"custom_status2_count":0,"custom_status3_count":0,"custom_status4_count":0,"custom_status5_count":0,"custom_status6_count":0,"custom_status7_count":0,"project_id":9,"plan_id":null,"created_on":1419364929,"created_by":1,"url":"http:\\/\\/testrail.local\\/\\/index.php?\\/runs\\/view\\/22"}';
+$mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
+
+}
+
+
 {
 
 $VAR1 = 'index.php?/api/v2/add_milestone/9';
@@ -850,7 +909,7 @@ $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4,
 
 {
 
-$VAR1 = 'index.php?/api/v2/get_tests/22';
+$VAR1 = 'index.php?/api/v2/get_tests/2';
 $VAR2 = '200';
 $VAR3 = 'OK';
 $VAR4 = bless( {
@@ -870,7 +929,34 @@ $VAR4 = bless( {
                  'content-type' => 'application/json; charset=utf-8',
                  'server' => 'Apache/2.4.7 (Ubuntu)'
                }, 'HTTP::Headers' );
-$VAR5 = '[{"id":15,"case_id":8,"status_id":3,"assignedto_id":null,"run_id":22,"title":"STROGGIFY POPULATION CENTERS","type_id":6,"priority_id":4,"estimate":null,"estimate_forecast":null,"refs":null,"milestone_id":null,"custom_preconds":null,"custom_steps":null,"custom_expected":null}]';
+$VAR5 = '[{"id":15,"case_id":8,"status_id":3,"assignedto_id":null,"run_id":22,"title":"faker.test","type_id":6,"priority_id":4,"estimate":null,"estimate_forecast":null,"refs":null,"milestone_id":null,"custom_preconds":null,"custom_steps":null,"custom_expected":null}]';
+$mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
+
+}
+
+{
+
+$VAR1 = 'index.php?/api/v2/get_tests/1';
+$VAR2 = '200';
+$VAR3 = 'OK';
+$VAR4 = bless( {
+                 'connection' => 'close',
+                 'x-powered-by' => 'PHP/5.5.9-1ubuntu4.5',
+                 'client-response-num' => 1,
+                 'date' => 'Tue, 23 Dec 2014 20:02:10 GMT',
+                 'client-peer' => '192.168.122.217:80',
+                 'content-length' => '276',
+                 '::std_case' => {
+                                   'client-date' => 'Client-Date',
+                                   'x-powered-by' => 'X-Powered-By',
+                                   'client-response-num' => 'Client-Response-Num',
+                                   'client-peer' => 'Client-Peer'
+                                 },
+                 'client-date' => 'Tue, 23 Dec 2014 20:02:11 GMT',
+                 'content-type' => 'application/json; charset=utf-8',
+                 'server' => 'Apache/2.4.7 (Ubuntu)'
+               }, 'HTTP::Headers' );
+$VAR5 = '[{"id":15,"case_id":8,"status_id":3,"assignedto_id":null,"run_id":22,"title":"STORAGE TANKS SEARED","type_id":6,"priority_id":4,"estimate":null,"estimate_forecast":null,"refs":null,"milestone_id":null,"custom_preconds":null,"custom_steps":null,"custom_expected":null},{"id":15,"case_id":8,"status_id":3,"assignedto_id":null,"run_id":22,"title":"NOT SO SEARED AFTER ARR"}]';
 $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
 
 }
@@ -951,7 +1037,7 @@ $VAR4 = bless( {
                  'content-type' => 'application/json; charset=utf-8',
                  'server' => 'Apache/2.4.7 (Ubuntu)'
                }, 'HTTP::Headers' );
-$VAR5 = '[{"id":1,"name":"passed","label":"Passed","color_dark":6667107,"color_medium":9820525,"color_bright":12709313,"is_system":true,"is_untested":false,"is_final":true},{"id":2,"name":"blocked","label":"Blocked","color_dark":9474192,"color_medium":13684944,"color_bright":14737632,"is_system":true,"is_untested":false,"is_final":true},{"id":3,"name":"untested","label":"Untested","color_dark":11579568,"color_medium":15395562,"color_bright":15790320,"is_system":true,"is_untested":true,"is_final":false},{"id":4,"name":"retest","label":"Retest","color_dark":13026868,"color_medium":15593088,"color_bright":16448182,"is_system":true,"is_untested":false,"is_final":false},{"id":5,"name":"failed","label":"Failed","color_dark":14250867,"color_medium":15829135,"color_bright":16631751,"is_system":true,"is_untested":false,"is_final":true}]';
+$VAR5 = '[{"id":1,"name":"passed","label":"Passed","color_dark":6667107,"color_medium":9820525,"color_bright":12709313,"is_system":true,"is_untested":false,"is_final":true},{"id":2,"name":"blocked","label":"Blocked","color_dark":9474192,"color_medium":13684944,"color_bright":14737632,"is_system":true,"is_untested":false,"is_final":true},{"id":3,"name":"untested","label":"Untested","color_dark":11579568,"color_medium":15395562,"color_bright":15790320,"is_system":true,"is_untested":true,"is_final":false},{"id":4,"name":"retest","label":"Retest","color_dark":13026868,"color_medium":15593088,"color_bright":16448182,"is_system":true,"is_untested":false,"is_final":false},{"id":5,"name":"failed","label":"Failed","color_dark":14250867,"color_medium":15829135,"color_bright":16631751,"is_system":true,"is_untested":false,"is_final":true},{"id":6,"name":"skip","label":"Skipped"},{"id":7,"name":"todo_fail","label":"TODO (failed)"},{"id":8,"name":"todo_pass","label":"TODO (passed)"}]';
 $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
 
 }
@@ -983,6 +1069,61 @@ $mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4,
 
 }
 
+{
+
+$VAR1 = 'index.php?/api/v2/add_result/10';
+$VAR2 = '200';
+$VAR3 = 'OK';
+$VAR4 = bless( {
+                 'connection' => 'close',
+                 'x-powered-by' => 'PHP/5.5.9-1ubuntu4.5',
+                 'client-response-num' => 1,
+                 'date' => 'Tue, 23 Dec 2014 20:02:11 GMT',
+                 'client-peer' => '192.168.122.217:80',
+                 'content-length' => '174',
+                 '::std_case' => {
+                                   'client-date' => 'Client-Date',
+                                   'x-powered-by' => 'X-Powered-By',
+                                   'client-response-num' => 'Client-Response-Num',
+                                   'client-peer' => 'Client-Peer'
+                                 },
+                 'client-date' => 'Tue, 23 Dec 2014 20:02:11 GMT',
+                 'content-type' => 'application/json; charset=utf-8',
+                 'server' => 'Apache/2.4.7 (Ubuntu)'
+               }, 'HTTP::Headers' );
+$VAR5 = '{"id":9,"test_id":10,"status_id":1,"created_by":1,"created_on":1419364931,"assignedto_id":null,"comment":"REAPER FORCES INBOUND","version":null,"elapsed":null,"defects":null}';
+$mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
+
+}
+
+{
+
+$VAR1 = 'index.php?/api/v2/add_result/11';
+$VAR2 = '200';
+$VAR3 = 'OK';
+$VAR4 = bless( {
+                 'connection' => 'close',
+                 'x-powered-by' => 'PHP/5.5.9-1ubuntu4.5',
+                 'client-response-num' => 1,
+                 'date' => 'Tue, 23 Dec 2014 20:02:11 GMT',
+                 'client-peer' => '192.168.122.217:80',
+                 'content-length' => '174',
+                 '::std_case' => {
+                                   'client-date' => 'Client-Date',
+                                   'x-powered-by' => 'X-Powered-By',
+                                   'client-response-num' => 'Client-Response-Num',
+                                   'client-peer' => 'Client-Peer'
+                                 },
+                 'client-date' => 'Tue, 23 Dec 2014 20:02:11 GMT',
+                 'content-type' => 'application/json; charset=utf-8',
+                 'server' => 'Apache/2.4.7 (Ubuntu)'
+               }, 'HTTP::Headers' );
+$VAR5 = '{"id":9,"test_id":10,"status_id":1,"created_by":1,"created_on":1419364931,"assignedto_id":null,"comment":"REAPER FORCES INBOUND","version":null,"elapsed":null,"defects":null}';
+$mockObject->map_response(qr/\Q$VAR1\E/,HTTP::Response->new($VAR2, $VAR3, $VAR4, $VAR5));
+
+}
+
+
 {
 
 $VAR1 = 'index.php?/api/v2/get_results/15';

+ 61 - 0
lib/Test/Rail/Harness.pm

@@ -0,0 +1,61 @@
+# ABSTRACT: TestRail testing harness
+# PODNAME: Test::Rail::Harness
+package Test::Rail::Harness;
+
+use strict;
+use warnings;
+
+use base qw/TAP::Harness/;
+
+=head1 DESCRIPTION
+
+Connective tissue for App::Prove::Plugin::TestRail.  Nothing to see here...
+
+=cut
+
+# inject parser_class as Test::Rail::Parser.
+sub new {
+    my $class = shift;
+    my $arg_for = shift;
+    $arg_for->{parser_class} = 'Test::Rail::Parser';
+    my $self = $class->SUPER::new($arg_for);
+    return $self;
+}
+
+sub make_parser {
+    my ($self, $job) = @_;
+    my $args = $self->SUPER::_get_parser_args($job);
+
+    #XXX again, don't see any way of getting this downrange to my parser :(
+    $args->{'apiurl'}  = $ENV{'TESTRAIL_APIURL'};
+    $args->{'user'}    = $ENV{'TESTRAIL_USER'};
+    $args->{'pass'}    = $ENV{'TESTRAIL_PASS'};
+    $args->{'project'} = $ENV{'TESTRAIL_PROJ'};
+    $args->{'run'}     = $ENV{'TESTRAIL_RUN'};
+    $args->{'case_per_ok'}  = $ENV{'TESTRAIL_CASEOK'};
+    $args->{'step_results'} = $ENV{'TESTRAIL_STEPS'};
+
+    $self->SUPER::_make_callback( 'parser_args', $args, $job->as_array_ref );
+    my $parser = $self->SUPER::_construct( $self->SUPER::parser_class, $args );
+
+    $self->SUPER::_make_callback( 'made_parser', $parser, $job->as_array_ref );
+    my $session = $self->SUPER::formatter->open_test( $job->description, $parser );
+
+    return ( $parser, $session );
+}
+
+1;
+
+__END__
+
+=head1 SEE ALSO
+
+L<TestRail::API>
+
+L<Test::Rail::Parser>
+
+L<App::Prove>
+
+=head1 SPECIAL THANKS
+
+Thanks to cPanel Inc, for graciously funding the creation of this module.

+ 358 - 0
lib/Test/Rail/Parser.pm

@@ -0,0 +1,358 @@
+# ABSTRACT: Upload your TAP results to TestRail
+# PODNAME: Test::Rail::Parser
+
+package Test::Rail::Parser;
+
+use strict;
+use warnings;
+use utf8;
+
+use parent qw/TAP::Parser/;
+use Carp qw{cluck confess};
+
+use TestRail::API;
+use Scalar::Util qw{reftype};
+
+use File::Basename qw{basename};
+
+our $self;
+
+=head1 DESCRIPTION
+
+A TAP parser which will upload your test results to a TestRail install.
+Has several options as to how you might want to upload said results.
+
+Subclass of L<TAP::Parser>, see that for usage past the constructor.
+
+You should probably use L<App::Prove::Plugin::TestRail> or L<testrail-report> for day-to-day usage...
+unless you need to subclass this.  In that case a couple of options have been exposed for your convenience.
+
+=cut
+
+=head1 CONSTRUCTOR
+
+=head2 B<new(OPTIONS)>
+
+Get the TAP Parser ready to talk to TestRail, and register a bunch of callbacks to upload test results.
+
+=over 4
+
+=item B<OPTIONS> - HASHREF -- Keys are as follows:
+
+=over 4
+
+=item B<apiurl> - STRING: Full URI to your testRail's indexDocument.
+
+=item B<user> - STRING: Name of your TestRail user.
+
+=item B<pass> - STRING: Said user's password.
+
+=item B<debug> - BOOLEAN: Print a bunch of extra messages
+
+=item B<browser> - OBJECT: Something like an LWP::UserAgent.  Useful for mocking with L<Test::LWP::UserAgent::TestRailMock>.
+
+=item B<run> - STRING (optional): name of desired run. Required if run_id not passed.
+
+=item B<run_id> - INTEGER (optional): ID of desired run. Required if run not passed.
+
+=item B<project> - STRING (optional): name of project containing your desired run.  Required if project_id not passed.
+
+=item B<project_id> - INTEGER (optional): ID of project containing your desired run.  Required if project not passed.
+
+=item B<step_results> - STRING (optional): 'internal name' of the 'step_results' type field available for your project.  Mutually exclusive with case_per_ok
+
+=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.
+
+=item B<result_options> - HASHREF (optional): Extra options to set with your result.  See L<TestRail::API>'s createTestResult function for more information.
+
+=item B<custom_options> - HASHREF (optional): Custom options to set with your result.  See L<TestRail::API>'s createTestResult function for more information.  step_results will be set here, if the option is passed.
+
+=back
+
+=back
+
+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.
+In both this mode and step_results, the file name of the test is expected to correspond to the test name in TestRail.
+
+=cut
+
+sub new {
+    my ($class,$opts) = @_;
+    our $self;
+
+    #Load our callbacks
+    $opts->{'callbacks'} = {
+        'test'    => \&testCallback,
+        'comment' => \&commentCallback,
+        'unknown' => \&unknownCallback,
+        'EOF'     => \&EOFCallback
+    };
+
+    my $tropts = {
+        'apiurl'       => delete $opts->{'apiurl'},
+        'user'         => delete $opts->{'user'},
+        'pass'         => delete $opts->{'pass'},
+        'debug'        => delete $opts->{'debug'},
+        'browser'      => delete $opts->{'browser'},
+        'run'          => delete $opts->{'run'},
+        'run_id'       => delete $opts->{'run_id'},
+        'project'      => delete $opts->{'project'},
+        'project_id'   => delete $opts->{'project_id'},
+        'step_results' => delete $opts->{'step_results'},
+        'case_per_ok'  => delete $opts->{'case_per_ok'},
+        #Stubs for extension by subclassers
+        'result_options'        => delete $opts->{'result_options'},
+        'result_custom_options' => delete $opts->{'result_custom_options'}
+    };
+
+    #Allow natural confessing from constructor
+    my $tr = TestRail::API->new($tropts->{'apiurl'},$tropts->{'user'},$tropts->{'pass'},$tropts->{'debug'});
+    $tropts->{'testrail'} = $tr;
+    $tr->{'browser'} = $tropts->{'browser'} if defined($tropts->{'browser'}); #allow mocks
+    $tr->{'debug'} = 0; #Always suppress in production
+
+    #Get project ID from name, if not provided
+    if (!defined($tropts->{'project_id'})) {
+        my $pname = $tropts->{'project'};
+        $tropts->{'project'} = $tr->getProjectByName($pname);
+        confess("Could not list projects! Shutting down.") if ($tropts->{'project'} == -500);
+        if (!$tropts->{'project'}) {
+            confess("No project (or project_id) provided, or that which was provided was invalid!");
+        }
+    } else {
+        $tropts->{'project'} = $tr->getProjectByID($tropts->{'project_id'});
+        confess("No such project with ID $tropts->{project_id}!") if !$tropts->{'project'};
+    }
+    $tropts->{'project_id'} = $tropts->{'project'}->{'id'};
+
+    #Discover possible test statuses
+    $tropts->{'statuses'} = $tr->getPossibleTestStatuses();
+    my @ok = grep {$_->{'name'} eq 'passed'} @{$tropts->{'statuses'}};
+    my @not_ok = grep {$_->{'name'} eq 'failed'} @{$tropts->{'statuses'}};
+    my @skip = grep {$_->{'name'} eq 'skip'} @{$tropts->{'statuses'}};
+    my @todof = grep {$_->{'name'} eq 'todo_fail'} @{$tropts->{'statuses'}};
+    my @todop = grep {$_->{'name'} eq 'todo_pass'} @{$tropts->{'statuses'}};
+    confess("No status with internal name 'passed' in TestRail!") unless scalar(@ok);
+    confess("No status with internal name 'failed' in TestRail!") unless scalar(@not_ok);
+    confess("No status with internal name 'skip' in TestRail!") unless scalar(@skip);
+    confess("No status with internal name 'todo_fail' in TestRail!") unless scalar(@todof);
+    confess("No status with internal name 'todo_pass' in TestRail!") unless scalar(@todop);
+    $tropts->{'ok'} = $ok[0];
+    $tropts->{'not_ok'} = $not_ok[0];
+    $tropts->{'skip'} = $skip[0];
+    $tropts->{'todo_fail'} = $todof[0];
+    $tropts->{'todo_pass'} = $todop[0];
+
+    #Grab suite from run
+    my $run_id = $tropts->{'run_id'};
+    if ($tropts->{'run'}) {
+        my $run = $tr->getRunByName($tropts->{'project_id'},$tropts->{'run'});
+        if (defined($run) && (reftype($run) || 'undef') eq 'HASH') {
+            $tropts->{'run'} = $run;
+            $tropts->{'run_id'} = $run->{'id'};
+        }
+    } else {
+        $tropts->{'run'} = $tr->getRunByID($run_id);
+    }
+    confess("No run ID provided, and no run with specified name exists!") if !$tropts->{'run_id'};
+
+    $self = $class->SUPER::new($opts);
+    if (defined($self->{'_iterator'}->{'command'}) && reftype($self->{'_iterator'}->{'command'}) eq 'ARRAY' ) {
+        $self->{'file'} = $self->{'_iterator'}->{'command'}->[-1];
+        print "PROCESSING RESULTS FROM TEST FILE: $self->{'file'}\n";
+    }
+
+    #Make sure the step results field passed exists on the system
+    $tropts->{'step_results'} = $tr->getTestResultFieldByName($tropts->{'step_results'},$tropts->{'project_id'}) if defined $tropts->{'step_results'};
+
+    $self->{'tr_opts'} = $tropts;
+    $self->{'errors'}  = 0;
+
+    return $self;
+}
+
+
+# Look for file boundaries, etc.
+sub unknownCallback {
+    my (@args) = @_;
+    our $self;
+    my $line = $args[0]->as_string;
+
+    #try to pick out the filename if we are running this on TAP in files
+
+    #old cpprove
+    if ($line =~ /^Running\s(.*)/) {
+        #TODO figure out which testsuite this implies
+        $self->{'file'} = $1;
+        print "PROCESSING RESULTS FROM TEST FILE: $self->{'file'}\n";
+    }
+    #RAW tap
+    if ($line =~ /(.*)\s\.\./) {
+        $self->{'file'} = $1;
+        print "PROCESSING RESULTS FROM TEST FILE: $self->{'file'}\n";
+    }
+    print "$line\n" if ($line =~ /^error/i);
+}
+
+# Register the current suite or test desc for use by test callback, if the line begins with the special magic words
+sub commentCallback {
+    my (@args) = @_;
+    our $self;
+    my $line = $args[0]->as_string;
+
+    if ($line =~ m/^#TESTDESC:\s*/) {
+        $self->{'tr_opts'}->{'test_desc'} = $line;
+        $self->{'tr_opts'}->{'test_desc'} =~ s/^#TESTDESC:\s*//g;
+        return;
+    }
+
+    #keep all comments before a test that aren't these special directives to save in NOTES field of reportTCResult
+    $self->{'tr_opts'}->{'test_notes'} .= $line;
+}
+
+sub testCallback {
+    my (@args) = @_;
+    my $test = $args[0];
+    our $self;
+
+    #Don't do anything if we don't want to map TR case => ok or use step-by-step results
+    if ( !($self->{'tr_opts'}->{'step_results'} || $self->{'tr_opts'}->{'case_per_ok'}) ) {
+        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'};
+        return 1;
+    }
+    if ($self->{'tr_opts'}->{'step_results'} && $self->{'tr_opts'}->{'case_per_ok'}) {
+        cluck("ERROR: step_options and case_per_ok options are mutually exclusive!");
+        $self->{'errors'}++;
+        return 0;
+    }
+    #Fail on unplanned tests
+    if ($test->is_unplanned()) {
+        cluck("ERROR: Unplanned test detected.  Will not attempt to upload results.");
+        $self->{'errors'}++;
+        return 0;
+    }
+
+    #Default assumption is that case name is step text (case_per_ok), unless...
+    my $line = $test->as_string;
+    $line =~ s/^(ok|not ok)\s[0-9]*\s-\s//g;
+    my $test_name  = $line;
+    my $run_id     = $self->{'tr_opts'}->{'run_id'};
+
+    print "Assuming test name is '$test_name'...\n" if $self->{'tr_opts'}->{'debug'} && !$self->{'tr_opts'}->{'step_results'};
+
+    #Setup args to pass to function
+    my $status = $self->{'tr_opts'}->{'not_ok'}->{'id'};
+    if ($test->is_actual_ok()) {
+        $status = $self->{'tr_opts'}->{'ok'}->{'id'};
+        $status = $self->{'tr_opts'}->{'skip'}->{'id'} if $test->has_skip();
+        $status = $self->{'tr_opts'}->{'todo_pass'}->{'id'} if $test->has_todo();
+    } else {
+        $status = $self->{'tr_opts'}->{'todo_fail'}->{'id'} if $test->has_todo();
+    }
+
+    #Setup step options and exit if that's the mode we be rollin'
+    if ($self->{'tr_opts'}->{'step_results'}) {
+        $self->{'tr_opts'}->{'result_custom_options'} = {} if !defined $self->{'tr_opts'}->{'result_custom_options'};
+        $self->{'tr_opts'}->{'result_custom_options'}->{'step_results'} = [] if !defined $self->{'tr_opts'}->{'result_custom_options'}->{'step_results'};
+        #XXX Obviously getting the 'expected' and 'actual' from the tap DIAGs would be ideal
+        push(
+            @{$self->{'tr_opts'}->{'result_custom_options'}->{'step_results'}},
+            TestRail::API::buildStepResults($line,"Good result","Bad Result",$status)
+        );
+        print "Appended step results.\n" if $self->{'tr_opts'}->{'debug'};
+        return 1;
+    }
+
+    #Optional args
+    my $notes          = $self->{'tr_opts'}->{'test_notes'};
+    my $options        = $self->{'tr_opts'}->{'result_options'};
+    my $custom_options = $self->{'tr_opts'}->{'result_custom_options'};
+
+    _set_result($run_id,$test_name,$status,$notes,$options,$custom_options);
+
+    #Blank out test description in anticipation of next test
+    # also blank out notes
+    $self->{'tr_opts'}->{'test_notes'} = undef;
+    $self->{'tr_opts'}->{'test_desc'} = undef;
+}
+
+sub EOFCallback {
+    our $self;
+
+    if (!(!$self->{'tr_opts'}->{'step_results'} xor $self->{'tr_opts'}->{'case_per_ok'})) {
+        print "Nothing left to do.\n";
+        undef $self->{'tr_opts'};
+        return 1;
+    }
+
+    #Fail if the file is not set
+    if (!defined($self->{'file'})) {
+        cluck("ERROR: Cannot detect filename, will not be able to find a Test Case with that name");
+        $self->{'errors'}++;
+        return 0;
+    }
+
+    my $run_id     = $self->{'tr_opts'}->{'run_id'};
+    my $test_name  = basename($self->{'file'});
+
+    my $status = $self->{'tr_opts'}->{'ok'}->{'id'};
+    $status = $self->{'tr_opts'}->{'not_ok'}->{'id'} if $self->has_problems();
+
+    #Optional args
+    my $notes          = $self->{'tr_opts'}->{'test_notes'};
+    my $options        = $self->{'tr_opts'}->{'result_options'};
+    my $custom_options = $self->{'tr_opts'}->{'result_custom_options'};
+
+
+    print "Setting results...\n";
+    my $cres = _set_result($run_id,$test_name,$status,$notes,$options,$custom_options);
+
+    undef $self->{'tr_opts'};
+
+    return $cres;
+}
+
+sub _set_result {
+    my ($run_id,$test_name,$status,$notes,$options,$custom_options) = @_;
+    our $self;
+    my $tc;
+
+    print "Attempting to find case by title '".$test_name."'...\n";
+    $tc = $self->{'tr_opts'}->{'testrail'}->getTestByName($run_id,$test_name);
+    if (!defined($tc) || (reftype($tc) || 'undef') ne 'HASH') {
+        cluck("ERROR: Could not find test case: $tc");
+        $self->{'errors'}++;
+        return 0;
+    }
+    my $xid = $tc ? $tc->{'id'} : '???';
+
+    my $cres;
+
+    #Set test result
+    if ($tc) {
+        print "Reporting result of case $xid in run $self->{'tr_opts'}->{'run_id'} as status '$status'...";
+        # createTestResults(test_id,status_id,comment,options,custom_options)
+        $cres = $self->{'tr_opts'}->{'testrail'}->createTestResults($tc->{'id'},$status, $notes, $options, $custom_options);
+        print "OK! (set to $status)\n" if (reftype($cres) || 'undef') eq 'HASH';
+    }
+    if (!$tc || ((reftype($cres) || 'undef') ne 'HASH') ) {
+        print "Failed!\n";
+        print "No Such test case in TestRail ($xid).\n";
+        $self->{'errors'}++;
+    }
+
+}
+
+1;
+
+__END__
+
+=head1 SEE ALSO
+
+L<TestRail::API>
+
+L<TAP::Parser>
+
+=head1 SPECIAL THANKS
+
+Thanks to cPanel Inc, for graciously funding the creation of this module.

+ 34 - 2
lib/TestRail/API.pm

@@ -83,6 +83,7 @@ sub new {
         flattree         => [],
         user_cache       => [],
         type_cache       => [],
+        tr_fields        => undef,
         default_request  => undef,
         browser          => new LWP::UserAgent()
     };
@@ -1071,7 +1072,7 @@ Gets run by name.
 
 Returns run definition HASHREF.
 
-    $tr->getRunByName(1,'gravy');
+    $tr->getRunByName(1,'R2');
 
 =cut
 
@@ -1498,7 +1499,38 @@ Returns ARRAYREF of result definition HASHREFs.
 sub getTestResultFields {
     my $self = shift;
     confess("Object methods must be called by an instance") unless ref($self);
-    return $self->_doRequest('index.php?/api/v2/get_result_fields');
+    return $self->{'tr_fields'} if defined($self->{'tr_fields'}); #cache
+    $self->{'tr_fields'} = $self->_doRequest('index.php?/api/v2/get_result_fields');
+    return $self->{'tr_fields'};
+}
+
+=head2 B<getTestResultFieldByName(SYSTEM_NAME,PROJECT_ID)>
+
+Gets a test result field by it's system name.  Optionally filter by project ID.
+
+=over 4
+
+=item B<SYSTEM NAME> - STRING: system name of a result field.
+
+=item B<PROJECT ID> - INTEGER (optional): Filter by whether or not the field is enabled for said project
+
+=back
+
+=cut
+
+sub getTestResultFieldByName {
+    my ($self,$system_name,$project_id) = @_;
+    confess("Object methods must be called by an instance") unless ref($self);
+    confess("System name must be string") unless $self->_checkString($system_name);
+    my @candidates = grep {$_->{'name'} eq $system_name} @{$self->getTestResultFields()};
+    return 0 if !scalar(@candidates);
+    if (defined $project_id) {
+        @candidates = grep {
+            $_->{'configs'}->[0]->{'context'}->{'is_global'} ||
+            ( grep {$_ == $project_id} @{ $_->{'configs'}->[0]->{'context'}->{'project_ids'} } )
+        } @candidates;
+    }
+    return $candidates[0];
 }
 
 =head2 B<getPossibleTestStatuses()>

+ 131 - 0
t/Test-Rail-Parser.t

@@ -0,0 +1,131 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use TestRail::API;
+use Test::LWP::UserAgent::TestRailMock;
+use Test::Rail::Parser;
+use Test::More 'tests' => 12;
+use Test::Fatal qw{exception};
+
+#Same song and dance as in TestRail-API.t
+my $apiurl = $ENV{'TESTRAIL_API_URL'};
+my $login  = $ENV{'TESTRAIL_USER'};
+my $pw     = $ENV{'TESTRAIL_PASSWORD'};
+my $is_mock = (!$apiurl && !$login && !$pw);
+
+($apiurl,$login,$pw) = ('http://testrail.local','teodesian@cpan.org','fake') if $is_mock;
+my ($debug,$browser);
+
+if ($is_mock) {
+    $debug = 1;
+    $browser = $Test::LWP::UserAgent::TestRailMock::mockObject;
+}
+
+#test exceptions...
+#TODO
+
+#case_per_ok mode
+
+my $fcontents = "
+fake.test ..
+1..2
+ok 1 - STORAGE TANKS SEARED
+#goo
+not ok 2 - NOT SO SEARED AFTER ARR
+";
+my $tap;
+my $res = exception {
+    $tap = Test::Rail::Parser->new({
+        'tap'                 => $fcontents,
+        'apiurl'              => $apiurl,
+        'user'                => $login,
+        'pass'                => $pw,
+        'debug'               => $debug,
+        'browser'             => $browser,
+        'run'                 => 'TestingSuite',
+        'project'             => 'TestProject',
+        'merge'               => 1,
+        'case_per_ok'         => 1
+    });
+};
+is($res,undef,"TR Parser doesn't explode on instantiation");
+isa_ok($tap,"Test::Rail::Parser");
+
+if (!$res) {
+    $tap->run();
+    is($tap->{'errors'},0,"No errors encountered uploading case results");
+}
+
+undef $tap;
+$res = exception {
+    $tap = Test::Rail::Parser->new({
+        'source'              => 'fake.test',
+        'apiurl'              => $apiurl,
+        'user'                => $login,
+        'pass'                => $pw,
+        'debug'               => $debug,
+        'browser'             => $browser,
+        'run'                 => 'TestingSuite',
+        'project'             => 'TestProject',
+        'merge'               => 1,
+        'case_per_ok'         => 1
+    });
+};
+is($res,undef,"TR Parser doesn't explode on instantiation");
+isa_ok($tap,"Test::Rail::Parser");
+
+if (!$res) {
+    $tap->run();
+    is($tap->{'errors'},0,"No errors encountered uploading case results");
+}
+
+#Time for non case_per_ok mode
+undef $tap;
+$res = exception {
+    $tap = Test::Rail::Parser->new({
+        'source'              => 'faker.test',
+        'apiurl'              => $apiurl,
+        'user'                => $login,
+        'pass'                => $pw,
+        'debug'               => $debug,
+        'browser'             => $browser,
+        'run'                 => 'OtherOtherSuite',
+        'project'             => 'TestProject',
+        'merge'               => 1,
+        'step_results'        => 'step_results'
+    });
+};
+is($res,undef,"TR Parser doesn't explode on instantiation");
+isa_ok($tap,"Test::Rail::Parser");
+
+if (!$res) {
+    $tap->run();
+    is($tap->{'errors'},0,"No errors encountered uploading case results");
+}
+
+#Default mode
+undef $tap;
+$res = exception {
+    $tap = Test::Rail::Parser->new({
+        'source'              => 'faker.test',
+        'apiurl'              => $apiurl,
+        'user'                => $login,
+        'pass'                => $pw,
+        'debug'               => $debug,
+        'browser'             => $browser,
+        'run'                 => 'OtherOtherSuite',
+        'project'             => 'TestProject',
+        'merge'               => 1
+    });
+};
+is($res,undef,"TR Parser doesn't explode on instantiation");
+isa_ok($tap,"Test::Rail::Parser");
+
+if (!$res) {
+    $tap->run();
+    is($tap->{'errors'},0,"No errors encountered uploading case results");
+}
+
+0;

+ 0 - 1
t/TestRail-API.t

@@ -18,7 +18,6 @@ my $pw     = $ENV{'TESTRAIL_PASSWORD'};
 
 #Mock if nothing is provided
 my $is_mock = (!$apiurl && !$login && !$pw);
-#$is_mock = 1;
 
 #EXAMPLE:
 #my $apiurl = 'http://testrails.cpanel.qa/testrail';

+ 10 - 0
t/fake.test

@@ -0,0 +1,10 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use Test::More 'tests' => 2;
+
+pass('STORAGE TANKS SEARED');
+note('whee');
+fail('NOT SO SEARED AFTER ARR');

+ 11 - 0
t/faker.test

@@ -0,0 +1,11 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use Test::More 'tests' => 2;
+
+is('expected','expected','Expected result OK');
+is('expected','unexpected','Unexpected result not OK');
+
+0;