itriagetestrail 1.0.36
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/itriagetestrail.rb +254 -0
- data/lib/itriagetestrail/framework_bindings/trcucumber13.rb +46 -0
- data/lib/itriagetestrail/framework_bindings/trcucumber20.rb +44 -0
- data/lib/itriagetestrail/framework_bindings/trcucumber30.rb +44 -0
- data/lib/itriagetestrail/framework_bindings/trminitest.rb +29 -0
- data/lib/itriagetestrail/pool.rb +33 -0
- data/lib/itriagetestrail/testrail_binding.rb +187 -0
- data/lib/itriagetestrail/testrail_objects/milestones.rb +135 -0
- data/lib/itriagetestrail/testrail_objects/projects.rb +52 -0
- data/lib/itriagetestrail/testrail_objects/sections.rb +46 -0
- data/lib/itriagetestrail/testrail_objects/suites.rb +32 -0
- data/lib/itriagetestrail/testrail_objects/test_cases.rb +83 -0
- data/lib/itriagetestrail/testrail_objects/test_plans.rb +91 -0
- data/lib/itriagetestrail/testrail_objects/test_results.rb +131 -0
- data/lib/itriagetestrail/testrail_objects/test_runs.rb +53 -0
- data/lib/itriagetestrail/version.rb +3 -0
- metadata +127 -0
@@ -0,0 +1,187 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# TestRail API binding for Ruby (API v2, available since TestRail 3.0)
|
5
|
+
#
|
6
|
+
# Learn more:
|
7
|
+
#
|
8
|
+
# http://docs.gurock.com/testrail-api2/start
|
9
|
+
# http://docs.gurock.com/testrail-api2/accessing
|
10
|
+
#
|
11
|
+
# Copyright Gurock Software GmbH. See license.md for details.
|
12
|
+
#
|
13
|
+
|
14
|
+
require 'net/http'
|
15
|
+
require 'net/https'
|
16
|
+
require 'uri'
|
17
|
+
require 'json'
|
18
|
+
|
19
|
+
module TestRail
|
20
|
+
class APIClient
|
21
|
+
@url = ''
|
22
|
+
@user = ''
|
23
|
+
@password = ''
|
24
|
+
@response_code = nil
|
25
|
+
|
26
|
+
attr_accessor :user
|
27
|
+
attr_accessor :password
|
28
|
+
attr_reader :response_code
|
29
|
+
|
30
|
+
def initialize(base_url)
|
31
|
+
valid_regex = %r{/\/$/}
|
32
|
+
base_url += '/' unless valid_regex.match(base_url)
|
33
|
+
@url = base_url + 'index.php?/api/v2/'
|
34
|
+
end
|
35
|
+
|
36
|
+
#
|
37
|
+
# Send Get
|
38
|
+
#
|
39
|
+
# Issues a GET request (read) against the API and returns the result
|
40
|
+
# (as Ruby hash).
|
41
|
+
#
|
42
|
+
# Arguments:
|
43
|
+
#
|
44
|
+
# uri The API method to call including parameters
|
45
|
+
# (e.g. get_case/1)
|
46
|
+
#
|
47
|
+
def send_get(uri)
|
48
|
+
res = read_cache(uri)
|
49
|
+
res || _send_request('GET', uri, nil, true)
|
50
|
+
end
|
51
|
+
|
52
|
+
#
|
53
|
+
# Send POST
|
54
|
+
#
|
55
|
+
# Issues a POST request (write) against the API and returns the result
|
56
|
+
# (as Ruby hash).
|
57
|
+
#
|
58
|
+
# Arguments:
|
59
|
+
#
|
60
|
+
# uri The API method to call including parameters
|
61
|
+
# (e.g. add_case/1)
|
62
|
+
# data The data to submit as part of the request (as
|
63
|
+
# Ruby hash, strings must be UTF-8 encoded)
|
64
|
+
#
|
65
|
+
def send_post(uri, data)
|
66
|
+
_send_request('POST', uri, data)
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def make_connection(url)
|
72
|
+
conn = Net::HTTP.new(url.host, url.port)
|
73
|
+
if url.scheme == 'https'
|
74
|
+
conn.use_ssl = true
|
75
|
+
conn.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
76
|
+
end
|
77
|
+
conn
|
78
|
+
end
|
79
|
+
|
80
|
+
def _parse_result(response)
|
81
|
+
if response.body && !response.body.empty?
|
82
|
+
JSON.parse(response.body)
|
83
|
+
else
|
84
|
+
{}
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def _error_check(response, result)
|
89
|
+
return if response.code == '200'
|
90
|
+
error = if !result.nil? && result.key?('error')
|
91
|
+
'"' + result['error'] + '"'
|
92
|
+
else
|
93
|
+
'No additional error message received'
|
94
|
+
end
|
95
|
+
raise APIError, format('TestRail API returned HTTP %<response_code>s\n%<error>s',
|
96
|
+
response_code: response.code, error: error)
|
97
|
+
end
|
98
|
+
|
99
|
+
def _send_request(method, uri, data, write_cache = false)
|
100
|
+
url = URI.parse(@url + uri)
|
101
|
+
if method == 'POST'
|
102
|
+
request = Net::HTTP::Post.new(url.path + '?' + url.query)
|
103
|
+
request.body = JSON.dump(data)
|
104
|
+
else
|
105
|
+
request = Net::HTTP::Get.new(url.path + '?' + url.query)
|
106
|
+
end
|
107
|
+
request.basic_auth(@user, @password)
|
108
|
+
request.add_field('Content-Type', 'application/json')
|
109
|
+
|
110
|
+
conn = make_connection(url)
|
111
|
+
|
112
|
+
retry_count = 0
|
113
|
+
while retry_count < 10
|
114
|
+
response = conn.request(request)
|
115
|
+
@response_code = response.code
|
116
|
+
if @response_code == '429'
|
117
|
+
write_http_status_to_file('testrail_429s', url)
|
118
|
+
sleep(response.header['retry-after'].to_i)
|
119
|
+
elsif @response_code == '500'
|
120
|
+
puts response.to_s
|
121
|
+
# this might require different handling for 500 'Deadlock found when
|
122
|
+
# trying to get lock; try restarting transaction'
|
123
|
+
sleep(2)
|
124
|
+
else
|
125
|
+
write_http_status_to_file('testrail_200s', url)
|
126
|
+
break
|
127
|
+
end
|
128
|
+
retry_count += 1
|
129
|
+
end
|
130
|
+
|
131
|
+
result = _parse_result(response)
|
132
|
+
|
133
|
+
_error_check(response, result)
|
134
|
+
|
135
|
+
if write_cache
|
136
|
+
cache_to_file(uri, result)
|
137
|
+
end
|
138
|
+
|
139
|
+
result
|
140
|
+
end
|
141
|
+
|
142
|
+
# This method is only used publicly
|
143
|
+
def write_http_status_to_file(filename, endpoint)
|
144
|
+
Dir.mkdir('./tmp') unless File.exist?('./tmp')
|
145
|
+
file = File.open("./tmp/#{filename}", 'a')
|
146
|
+
file.write("#{Time.now},#{endpoint}\n")
|
147
|
+
file.close
|
148
|
+
end
|
149
|
+
|
150
|
+
def cache_to_file(url, content)
|
151
|
+
payload = {'response' => content}
|
152
|
+
Dir.mkdir('./tmp') unless File.exist?('./tmp')
|
153
|
+
filename = sanitize_filename(url)
|
154
|
+
file = File.open("./tmp/#{filename}", 'w')
|
155
|
+
file.write(payload.to_json)
|
156
|
+
file.close
|
157
|
+
end
|
158
|
+
|
159
|
+
def read_cache(url)
|
160
|
+
filename = sanitize_filename(url)
|
161
|
+
return unless File.exist?("./tmp/#{filename}")
|
162
|
+
file = File.open("./tmp/#{filename}", 'r')
|
163
|
+
content = JSON.parse(file.read)
|
164
|
+
file.close
|
165
|
+
content[:response]
|
166
|
+
end
|
167
|
+
|
168
|
+
def sanitize_filename(filename)
|
169
|
+
# Split the name when finding a period which is preceded by some
|
170
|
+
# character, and is followed by some character other than a period,
|
171
|
+
# if there is no following period that is followed by something
|
172
|
+
# other than a period (yeah, confusing, I know)
|
173
|
+
fn = filename.split /(?<=.)\.(?=[^.])(?!.*\.[^.])/m
|
174
|
+
|
175
|
+
# We now have one or two parts (depending on whether we could find
|
176
|
+
# a suitable period). For each of these parts, replace any unwanted
|
177
|
+
# sequence of characters with an underscore
|
178
|
+
fn.map! { |s| s.gsub /[^a-z0-9\-]+/i, '_' }
|
179
|
+
|
180
|
+
# Finally, join the parts with a period and return the result
|
181
|
+
return fn.join '.'
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
class APIError < StandardError
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'tzinfo'
|
4
|
+
|
5
|
+
module Itriagetestrail
|
6
|
+
module Milestones
|
7
|
+
def normalize_origin
|
8
|
+
case @testrail_config[:origin]
|
9
|
+
when 'prd', 'production', 'origin/production'
|
10
|
+
'Production'
|
11
|
+
when 'stg', 'staging', 'origin/staging'
|
12
|
+
'Staging'
|
13
|
+
when 'dev', 'development', 'origin/development'
|
14
|
+
'Development'
|
15
|
+
when 'local', ''
|
16
|
+
'Local'
|
17
|
+
when 'master', 'origin/master'
|
18
|
+
'Master'
|
19
|
+
else
|
20
|
+
'Dev Branch'
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Establish the milestone name based on origin passed in,
|
25
|
+
# usually origin represents a branch or environment
|
26
|
+
def normalize_milestone
|
27
|
+
if @testrail_config[:milestone].nil? || @testrail_config[:milestone].empty?
|
28
|
+
normalize_origin
|
29
|
+
else
|
30
|
+
@testrail_config[:milestone]
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# returns timestamp for begining of first day of current quarter
|
35
|
+
def milestone_period_start
|
36
|
+
time_zone = TZInfo::Timezone.get('America/Denver')
|
37
|
+
current_year = time_zone.now.year
|
38
|
+
month = time_zone.now.mon
|
39
|
+
|
40
|
+
# determine which quarter we are in
|
41
|
+
if month <= 3
|
42
|
+
time_zone.utc_to_local(Time.utc(current_year, 1, 1))
|
43
|
+
elsif month <= 6
|
44
|
+
time_zone.utc_to_local(Time.utc(current_year, 4, 1))
|
45
|
+
elsif month <= 9
|
46
|
+
time_zone.utc_to_local(Time.utc(current_year, 7, 1))
|
47
|
+
else
|
48
|
+
time_zone.utc_to_local(Time.utc(current_year, 10, 1))
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# determine the due date (end of quarter) for a new milestone being added
|
53
|
+
def milestone_due_date
|
54
|
+
time_zone = TZInfo::Timezone.get('America/Denver')
|
55
|
+
current_year = time_zone.now.year
|
56
|
+
month = time_zone.now.mon
|
57
|
+
|
58
|
+
# determine which quarter we are in
|
59
|
+
if month <= 3
|
60
|
+
Time.utc(current_year, 3, 31).strftime('%s')
|
61
|
+
elsif month <= 6
|
62
|
+
Time.utc(current_year, 6, 30).strftime('%s')
|
63
|
+
elsif month <= 9
|
64
|
+
Time.utc(current_year, 9, 30).strftime('%s')
|
65
|
+
else
|
66
|
+
Time.new(current_year, 12, 31).strftime('%s')
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# returns the id for a requested milestone by name, or creates one if the milestone does not exist
|
71
|
+
def fetch_milestone(requested_milestone_name)
|
72
|
+
milestones = @client.send_get("get_milestones/#{@project_id}")
|
73
|
+
res = -1
|
74
|
+
milestones.each do |milestone|
|
75
|
+
res = milestone['id'] if milestone['name'] == requested_milestone_name
|
76
|
+
end
|
77
|
+
|
78
|
+
if res == -1
|
79
|
+
# We need to add the milestone to TestRail
|
80
|
+
|
81
|
+
body = {
|
82
|
+
name: requested_milestone_name,
|
83
|
+
due_on: milestone_due_date
|
84
|
+
}
|
85
|
+
|
86
|
+
res = @client.send_post("add_milestone/#{@project_id}", body)['id']
|
87
|
+
end
|
88
|
+
res
|
89
|
+
end
|
90
|
+
|
91
|
+
# return a standardized name for a milestone to be archived
|
92
|
+
def milestone_archive_name(milestone_name, date)
|
93
|
+
year = date.year
|
94
|
+
month = date.mon
|
95
|
+
|
96
|
+
if month <= 3
|
97
|
+
"#{milestone_name} #{year}-Q1"
|
98
|
+
elsif month <= 6
|
99
|
+
"#{milestone_name} #{year}-Q2"
|
100
|
+
elsif month <= 9
|
101
|
+
"#{milestone_name} #{year}-Q3"
|
102
|
+
else
|
103
|
+
"#{milestone_name} #{year}-Q4"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# return all the runs associated with an existing milestone
|
108
|
+
def milestone_runs(milestone_name)
|
109
|
+
# use the matching milestone id for project
|
110
|
+
milestone_id = fetch_milestone(milestone_name)
|
111
|
+
|
112
|
+
# fetch all test runs associated with the milestone id for project
|
113
|
+
@client.send_get("get_runs/#{@project_id}&milestone_id=#{milestone_id}&is_completed=1") || []
|
114
|
+
end
|
115
|
+
|
116
|
+
# testrail call to rename a milestone (for archiving)
|
117
|
+
def rename_milestone(id, new_name)
|
118
|
+
# TODO: rename milestone with previous_milestone
|
119
|
+
body = { name: new_name }
|
120
|
+
@client.send_post("update_milestone/#{id}", body)['id']
|
121
|
+
end
|
122
|
+
|
123
|
+
# this archives a milestone at the turn of a quarter and creates a new one in its place
|
124
|
+
def reset_milestone(milestone_name)
|
125
|
+
runs = milestone_runs(milestone_name)
|
126
|
+
return if runs.empty?
|
127
|
+
last_run_time = Time.at(runs.last['completed_on'])
|
128
|
+
|
129
|
+
# if last run time is smaller than period start, do below
|
130
|
+
return unless last_run_time < milestone_period_start
|
131
|
+
rename_milestone(@milestone_id, milestone_archive_name(milestone_name, last_run_time))
|
132
|
+
@milestone_id = fetch_milestone(@milestone_name)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'itriagetestrail/version'
|
4
|
+
require 'itriagetestrail/testrail_binding'
|
5
|
+
require 'itriagetestrail/pool'
|
6
|
+
require 'tzinfo'
|
7
|
+
|
8
|
+
module Itriagetestrail
|
9
|
+
module Projects
|
10
|
+
# populate projects instance variable with all projects objects in the testrail site
|
11
|
+
def projects
|
12
|
+
@projects ||= @client.send_get('get_projects')
|
13
|
+
end
|
14
|
+
|
15
|
+
def project_by_name(name)
|
16
|
+
res = -1
|
17
|
+
projects.each do |project|
|
18
|
+
res = project if project['name'] == name
|
19
|
+
end
|
20
|
+
res
|
21
|
+
end
|
22
|
+
|
23
|
+
# return the project object for a given project id
|
24
|
+
def project_by_id(id)
|
25
|
+
res = -1
|
26
|
+
projects.each do |project|
|
27
|
+
res = project if project['id'] == id.to_i
|
28
|
+
end
|
29
|
+
res
|
30
|
+
end
|
31
|
+
|
32
|
+
# set the project_id by a requested project from config/environment variable
|
33
|
+
def set_project
|
34
|
+
requested_id = @testrail_config[:projectId]
|
35
|
+
case requested_id
|
36
|
+
when nil, ''
|
37
|
+
# a project id was not provided, fetch it from TestRail by project name
|
38
|
+
res = project_by_name(@testrail_config[:projectName])
|
39
|
+
if res == -1
|
40
|
+
@execute = false
|
41
|
+
return
|
42
|
+
end
|
43
|
+
@project_id = res['id']
|
44
|
+
@suite_mode = res['suite_mode']
|
45
|
+
else
|
46
|
+
# use the requested project id
|
47
|
+
@project_id = requested_id
|
48
|
+
@suite_mode = project_by_id(@project_id)['suite_mode']
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Itriagetestrail
|
4
|
+
module Sections
|
5
|
+
# TestRail Sections
|
6
|
+
def testrail_sections
|
7
|
+
case @suite_mode
|
8
|
+
when 2, 3
|
9
|
+
@suite_id = testrail_suite_id(@suite_name)
|
10
|
+
@sections = @client.send_get("get_sections/#{@project_id}&suite_id=#{@suite_id}")
|
11
|
+
else
|
12
|
+
@sections = @client.send_get("get_sections/#{@project_id}")
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def testrail_section_id(section_title)
|
17
|
+
res = -1
|
18
|
+
@sections.each do |section|
|
19
|
+
res = section['id'] if section['name'] == section_title
|
20
|
+
end
|
21
|
+
res
|
22
|
+
end
|
23
|
+
|
24
|
+
def add_testrail_section(section_title)
|
25
|
+
body = if @suite_name
|
26
|
+
{
|
27
|
+
name: section_title,
|
28
|
+
suite_id: testrail_suite_id(@suite_name)
|
29
|
+
}
|
30
|
+
else
|
31
|
+
{
|
32
|
+
name: section_title
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
res = @client.send_post("add_section/#{@project_id}", body)
|
37
|
+
|
38
|
+
testrail_section = res['id']
|
39
|
+
|
40
|
+
# re-establish sections
|
41
|
+
testrail_sections
|
42
|
+
|
43
|
+
testrail_section
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Itriagetestrail
|
4
|
+
module Suites
|
5
|
+
# TestRail Suites
|
6
|
+
def testrail_suites
|
7
|
+
case @suite_mode
|
8
|
+
when 2, 3
|
9
|
+
@suites = @client.send_get("get_suites/#{@project_id}")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def testrail_suite_id(suite_name)
|
14
|
+
res = -1
|
15
|
+
@suites.each do |suite|
|
16
|
+
res = suite['id'] if suite['name'] == suite_name
|
17
|
+
end
|
18
|
+
res
|
19
|
+
end
|
20
|
+
|
21
|
+
def add_testrail_suite(suite_name)
|
22
|
+
body = { name: suite_name }
|
23
|
+
res = @client.send_post("add_suite/#{@project_id}", body)
|
24
|
+
testrail_suite = res['id']
|
25
|
+
|
26
|
+
# re-establish suites
|
27
|
+
testrail_suites
|
28
|
+
|
29
|
+
testrail_suite
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|