浏览代码

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

George S. Baugh 11 年之前
父节点
当前提交
367dd11b0c
共有 11 个文件被更改,包括 1056 次插入14 次删除
  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;