itriagetestrail 1.0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|