qat-reporter-xray-sa 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,142 @@
1
+ require_relative 'publisher'
2
+
3
+ module QAT
4
+ module Reporter
5
+ class Xray
6
+ # QAT::Reporter::Xray configuration module
7
+ module Config
8
+ class << self
9
+
10
+ attr_accessor :project_key, :jira_url, :xray_default_api_url, :login_credentials, :publisher, :jira_type,
11
+ :cloud_xray_api_credentials, :xray_test_environment, :xray_test_version, :xray_test_revision, :xray_export_test_keys, :xray_export_test_filter
12
+
13
+ # Default xray API url (Jira Cloud)
14
+ DEFAULT_XRAY_URL = 'https://xray.cloud.getxray.app'
15
+
16
+ # Returns the xray instanced type (hosted or cloud)
17
+ def jira_type
18
+ @jira_type
19
+ end
20
+
21
+ # Returns the jira url
22
+ def jira_url
23
+ @jira_url
24
+ end
25
+
26
+ # Returns the default xray jira url for cloud api
27
+ def xray_default_api_url
28
+ DEFAULT_XRAY_URL
29
+ end
30
+
31
+ # Returns the login credentials array could -> [username, password, apiToken]
32
+ def login_credentials
33
+ @login_credentials
34
+ end
35
+
36
+ # Returns the login credentials array for cloud api [client_id, client_secret]
37
+ def cloud_xray_api_credentials
38
+ @cloud_xray_api_credentials || nil
39
+ end
40
+
41
+ # Returns the test keys to export
42
+ def xray_export_test_keys
43
+ @keys || nil
44
+ end
45
+
46
+ # Returns the test filter to export
47
+ def xray_export_test_filter
48
+ @filter || nil
49
+ end
50
+
51
+ # Returns the project key value
52
+ def project_key
53
+ @project_key
54
+ end
55
+
56
+ # Returns the xray test environment value
57
+ def xray_test_environment
58
+ @xray_test_environment || get_env_from_qat_config
59
+ end
60
+
61
+ # Returns the xray test version value
62
+ def xray_test_version
63
+ @xray_test_version || get_version_from_qat_config
64
+ end
65
+
66
+ # Returns the xray test revision value
67
+ def xray_test_revision
68
+ @xray_test_revision || get_revision_from_qat_config
69
+ end
70
+
71
+ def publisher=(publisher)
72
+ @publisher = publisher
73
+ end
74
+
75
+ def publisher
76
+ @publisher
77
+ end
78
+
79
+
80
+ private
81
+
82
+ def get_env_from_qat_config
83
+ begin
84
+ QAT.configuration.dig(:xray, :environment_name)
85
+ rescue ArgumentError
86
+ raise(NoEnvironmentDefined, 'JIRA\'s environment must be defined!')
87
+ end
88
+ end
89
+
90
+ def get_version_from_qat_config
91
+ begin
92
+ QAT.configuration.dig(:xray, :version)
93
+ rescue ArgumentError
94
+ raise(NoVersionDefined, 'JIRA\'s version must be defined!')
95
+ end
96
+ end
97
+
98
+ def get_revision_from_qat_config
99
+ begin
100
+ QAT.configuration.dig(:xray, :revision)
101
+ rescue ArgumentError
102
+ raise(NoRevisionDefined, 'JIRA\'s revision must be defined!')
103
+ end
104
+ end
105
+
106
+ # Error returned when the QAT project has not defined the Jira Environment
107
+ class NoEnvironmentDefined < StandardError
108
+ end
109
+ # Error returned when the QAT project has not defined the Jira Version
110
+ class NoVersionDefined < StandardError
111
+ end
112
+ # Error returned when the QAT project has not defined the Jira Revision
113
+ class NoRevisionDefined < StandardError
114
+ end
115
+ end
116
+ end
117
+
118
+ class << self
119
+ # Configures the QAT::Formatter::Xray
120
+ def configure(&block)
121
+ yield Config
122
+
123
+ QAT::Reporter::Xray::Config.publisher = QAT::Reporter::Xray::Publisher.const_get(QAT::Reporter::Xray::Config.jira_type.capitalize).new
124
+
125
+ raise(LoginCredentialsUndefinedError, 'JIRA\'s login credentials must be defined!') unless QAT::Reporter::Xray::Config.login_credentials
126
+ raise(PublisherUndefinedError, 'XRAY\'s publisher is not defined!') unless QAT::Reporter::Xray::Config.publisher.present?
127
+ return QAT::Reporter::Xray::Config.publisher
128
+ end
129
+
130
+ # Error returned when the the JIRA project key is not defined
131
+ class ProjectKeyUndefinedError < StandardError
132
+ end
133
+ # Error returned when the the JIRA login credentials is not defined
134
+ class LoginCredentialsUndefinedError < StandardError
135
+ end
136
+ # Error returned when not publisher was defined
137
+ class PublisherUndefinedError < StandardError
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,46 @@
1
+ require 'rest-client'
2
+ require 'json'
3
+
4
+ module QAT
5
+ module Reporter
6
+ class Xray
7
+ # QAT::Reporter::Xray::Issue represents an abstract Xray issue
8
+ class Issue
9
+
10
+ attr_reader :jira_id
11
+
12
+ # Initializes Xray Publisher url and login information
13
+ def initialize(jira_id = nil)
14
+ @jira_id = jira_id
15
+ if jira_id
16
+ raise(InvalidIssueType, "The given issue '#{jira_id}' type does not correspond!") unless valid_test_execution?
17
+ end
18
+ end
19
+
20
+ # Creates a issue
21
+ def create(data)
22
+ QAT::Reporter::Xray::Config.publisher.create_issue(data)
23
+ end
24
+
25
+ # Error returned when the the JIRA Issue does not correspond
26
+ class InvalidIssueType < StandardError
27
+ end
28
+ # Error returned when publisher string is not known
29
+ class PublisherNotKnownError < StandardError
30
+ end
31
+
32
+ private
33
+
34
+ def valid_test_execution?
35
+ base = Publisher::Base.new
36
+ response = JSON.parse(Publisher::Base::Client.new(base.base_url).get("/rest/api/2/issue/#{jira_id}", base.default_headers))
37
+ if response.dig('fields', 'issuetype', 'name').eql? 'Test Execution'
38
+ true
39
+ else
40
+ false
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,198 @@
1
+ require 'rest-client'
2
+ require 'json'
3
+ require 'active_support/core_ext/hash/keys'
4
+ require 'active_support/core_ext/string/inflections'
5
+
6
+ module QAT
7
+ module Reporter
8
+ class Xray
9
+ # QAT::Reporter::Xray::Publisher integrator module
10
+ module Publisher
11
+ # QAT::Reporter::Xray::Publisher::Base integrator class
12
+ class Base
13
+ attr_reader :base_url, :default_headers, :login_credentials, :default_cloud_api_url, :cloud_xray_api_credentials
14
+
15
+ # Initializes Xray Publisher url and login information
16
+ def initialize
17
+ @base_url = QAT::Reporter::Xray::Config.jira_url
18
+ @login_credentials = QAT::Reporter::Xray::Config.login_credentials
19
+ @default_cloud_api_url = QAT::Reporter::Xray::Config.xray_default_api_url
20
+ @cloud_xray_api_credentials = QAT::Reporter::Xray::Config.cloud_xray_api_credentials
21
+ end
22
+
23
+ # Creates a Jira issue
24
+ def create_issue(data)
25
+ Client.new(base_url).post('/rest/api/2/issue', data.to_json, default_headers)
26
+ end
27
+
28
+ # Get the default headers for Xray ('password' in Xray API is password)
29
+ def default_headers
30
+ headers = if QAT::Reporter::Xray::Config.jira_type == 'cloud'
31
+ auth_headers_jira_cloud
32
+ else
33
+ auth_headers
34
+ end
35
+ {
36
+ 'Content-Type': 'application/json'
37
+ }.merge(headers)
38
+ end
39
+
40
+ private
41
+
42
+ # Authentication header for xray, Basic authentication done with: username, password
43
+ def auth_headers
44
+ username = login_credentials[0]
45
+ password = login_credentials[1]
46
+ {
47
+ Authorization: "Basic #{::Base64::encode64("#{username}:#{password}").delete("\n")}"
48
+ }
49
+ end
50
+
51
+ # Authentication header for jira, Basic authentication done with: username, apiToken
52
+ def auth_headers_jira_cloud
53
+ username = login_credentials[0]
54
+ api_token = login_credentials[2]
55
+ {
56
+ Authorization: "Basic #{::Base64::encode64("#{username}:#{api_token}").delete("\n")}"
57
+ }
58
+ end
59
+
60
+ # REST Base Client implementation
61
+ class Client
62
+
63
+ # Service Unavailable Error class
64
+ class ServiceUnavailableError < StandardError
65
+ end
66
+
67
+ # Connection Error class
68
+ class ConnectionError < StandardError
69
+ end
70
+
71
+ # No Connection Error class
72
+ class NoConnectionFound < StandardError
73
+ end
74
+
75
+ attr_reader :base_uri
76
+
77
+ # Returns a new REST Base Client
78
+ # @return [RestClient::Response]
79
+ def initialize(base_uri)
80
+ # sets the ip:port/base_route
81
+ @base_uri = case base_uri
82
+ when Hash
83
+ URI::HTTP.build(base_uri).to_s
84
+ when URI::HTTP
85
+ base_uri.to_s
86
+ when String
87
+ base_uri
88
+ else
89
+ raise ArgumentError.new "Invalid URI class: #{base_uri.class}"
90
+ end
91
+ end
92
+
93
+ [:put, :post, :get, :delete, :patch].each do |operation|
94
+ define_method operation do |url, *args|
95
+ final_url = base_uri + url
96
+
97
+ # log_request operation, final_url, args
98
+ # begin
99
+ response = RestClient.method(operation).call(final_url, *args)
100
+ log_response response
101
+ response
102
+ # validate response
103
+ # rescue RestClient::ExceptionWithResponse => e
104
+ # puts e.response
105
+ # raise NoConnectionFound.new ('Jira was not found!!!')
106
+ # rescue => exception
107
+ # puts "#{exception.class} #{exception.message.to_s}"
108
+ # raise NoConnectionFound.new ('Jira was not found!!!')
109
+ # end
110
+ end
111
+ end
112
+
113
+ protected
114
+
115
+ # Validates the response and raises a HTTP Error
116
+ #@param response [RestClient::Response] response
117
+ def validate(response)
118
+ error_klass = case response.code
119
+ when 400 then
120
+ Error::BadRequest
121
+ when 401 then
122
+ Error::Unauthorized
123
+ when 403 then
124
+ Error::Forbidden
125
+ when 404 then
126
+ Error::NotFound
127
+ when 405 then
128
+ Error::MethodNotAllowed
129
+ when 409 then
130
+ Error::Conflict
131
+ when 422 then
132
+ Error::Unprocessable
133
+ when 500 then
134
+ Error::InternalServerError
135
+ when 502 then
136
+ Error::BadGateway
137
+ when 503 then
138
+ Error::ServiceUnavailable
139
+ end
140
+
141
+ raise error_klass.new response if error_klass
142
+ response
143
+ end
144
+
145
+ # Logs the request information
146
+ #@param operation [String] HTTP operation called
147
+ #@param url [String] target URL
148
+ #@param args [String] request arguments
149
+ #@see RestClient#get
150
+ def log_request(operation, url, args)
151
+ puts { "#{operation.upcase}: #{url}" }
152
+
153
+ args.each do |options|
154
+ log_http_options options
155
+ end
156
+ end
157
+
158
+ # Logs the received response
159
+ #@param response [RestClient::Response] response
160
+ def log_response(response)
161
+ puts "Response HTTP #{response.code} (#{response.body})"
162
+
163
+ log_http_options({ headers: response.headers.to_h,
164
+ body: response.body }.select { |_, value| !value.nil? })
165
+ end
166
+
167
+ # Logs the request's HTTP options
168
+ #@param options [Hash|String] http options to log
169
+ def log_http_options(options)
170
+ temp = if options.is_a?(String)
171
+ { payload: JSON.parse(options) }
172
+ else
173
+ options.map do |k, v|
174
+ if k == :body
175
+ begin
176
+ [k, JSON.pretty_generate(JSON.parse(v))]
177
+ # if body is not JSON by some unknown reason, we still want to print
178
+ rescue JSON::ParserError
179
+ [k, v]
180
+ end
181
+ else
182
+ [k, v]
183
+ end
184
+ end.to_h
185
+ end
186
+
187
+ temp.each do |key, value|
188
+ puts "#{key.to_s.humanize}:"
189
+ puts value
190
+ end
191
+ end
192
+
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,123 @@
1
+ require_relative 'base'
2
+ require 'zip'
3
+
4
+ module QAT
5
+ module Reporter
6
+ class Xray
7
+ module Publisher
8
+ # QAT::Reporter::Xray::Publisher::Cloud integrator class
9
+ class Cloud < Base
10
+
11
+ def get_jira_issue(issue_key)
12
+ headers = { 'Content-Type': 'application/json' }.merge(auth_headers)
13
+ Client.new(base_url).get("/rest/api/3/issue/#{issue_key}", headers)
14
+ end
15
+
16
+ def update_jira_issue(issue_key, payload)
17
+ headers = { 'Content-Type': 'application/json' }.merge(auth_headers)
18
+ Client.new(base_url).put("/rest/api/2/issue/#{issue_key}", payload.to_json, headers)
19
+ end
20
+
21
+ def get_jira_linked_issue(linked_issue_id)
22
+ headers = { 'Content-Type': 'application/json' }.merge(auth_headers)
23
+ Client.new(base_url).get("/rest/api/3/issueLink/#{linked_issue_id}", headers)
24
+ end
25
+
26
+ def get_project(project_key)
27
+ headers = { 'Content-Type': 'application/json' }.merge(auth_headers)
28
+ Client.new(base_url).get("/rest/api/3/project/#{project_key}", headers)
29
+ end
30
+
31
+ # Get workflow transitions of an issue
32
+ def get_transitions(issue_key)
33
+ headers = { 'Content-Type': 'application/json' }.merge(auth_headers)
34
+ Client.new(base_url).get("/rest/api/2/issue/#{issue_key}/transitions", headers)
35
+ end
36
+
37
+ # Change transition issue
38
+ def transitions_issue(issue_key, payload)
39
+ headers = { 'Content-Type': 'application/json' }.merge(auth_headers)
40
+ Client.new(base_url).post("/rest/api/2/issue/#{issue_key}/transitions", payload.to_json, headers)
41
+ end
42
+
43
+ # Posts the execution json results in Xray
44
+ def send_execution_results(results)
45
+ headers = { 'Content-Type': 'application/json' }.merge(auth_token)
46
+ Client.new(default_cloud_api_url).post('/api/v1/import/execution', results.to_json, headers)
47
+ end
48
+
49
+ # Get the Authorization Token based on client_id & client_secret (ONLY FOR CLOUD XRAY)
50
+ def auth_token
51
+ return @auth_token if @auth_token
52
+
53
+ client_id = cloud_xray_api_credentials[0]
54
+ client_secret = cloud_xray_api_credentials[1]
55
+ auth_header_cloud = {
56
+ client_id: client_id,
57
+ client_secret: client_secret
58
+ }
59
+
60
+ response = Client.new(default_cloud_api_url).post('/api/v1/authenticate', auth_header_cloud).body
61
+ bearer = JSON.parse(response)
62
+ @auth_token = {
63
+ Authorization: "Bearer #{bearer}"
64
+ }
65
+ end
66
+
67
+ def import_cucumber_behave_tests(info, results)
68
+ headers = { 'Content-Type': 'multipart/form-data' }.merge(auth_token)
69
+ payload = {
70
+ info: File.new(info, 'rb'),
71
+ results: File.new(results, 'rb')
72
+ }
73
+
74
+ Client.new(default_cloud_api_url).post('/api/v2/import/execution/behave/multipart', payload, headers)
75
+ end
76
+
77
+ # Import Cucumber features files as a zip file via API
78
+ # @param project_key [String] JIRA's project key
79
+ # @param file_path [String] Cucumber features files' zip file
80
+ # @see https://confluence.xpand-it.com/display/XRAYCLOUD/Importing+Cucumber+Tests+-+REST
81
+ def import_cucumber_tests(project_key, file_path, project_id = nil)
82
+ headers = auth_token.merge({
83
+ multipart: true,
84
+ params: {
85
+ projectKey: project_key,
86
+ projectId: project_id,
87
+ source: project_key
88
+ }
89
+ })
90
+ payload = { file: File.new(file_path, 'rb') }
91
+
92
+ Client.new(default_cloud_api_url).post('/api/v1/import/feature', payload, headers)
93
+ end
94
+
95
+ # Export Xray test scenarios to a zip file via API
96
+ # @param keys [String] test scenarios
97
+ # @param filter [String] project filter
98
+ # @see https://confluence.xpand-it.com/display/XRAYCLOUD/Exporting+Cucumber+Tests+-+REST
99
+ def export_test_scenarios(keys, filter = nil)
100
+ params = {
101
+ keys: keys,
102
+ }
103
+ params[:filter] = filter if filter.present?
104
+
105
+ headers = auth_token.merge(params: params)
106
+
107
+ puts "Exporting features from: #{default_cloud_api_url}/api/v1/export/cucumber"
108
+ all_tests = RestClient.get("#{default_cloud_api_url}/api/v1/export/cucumber", headers)
109
+ raise(NoTestsFoundError, "No Tests found for keys: #{keys}") if all_tests.code != 200
110
+ all_test_keys = all_tests.body.to_s.scan(/(@TEST_\w+-\d+)/).flatten
111
+ (0...all_test_keys.count).to_a.map do |index|
112
+ { test_issue_key: all_test_keys[index].gsub('@TEST_', '') }
113
+ end
114
+ end
115
+ end
116
+
117
+ # Error returned when no tests are found
118
+ class NoTestsFoundError < StandardError
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,54 @@
1
+ require 'base64'
2
+ require 'zip'
3
+ require_relative 'base'
4
+
5
+ module QAT
6
+ module Reporter
7
+ class Xray
8
+ module Publisher
9
+ # QAT::Reporter::Xray::Publisher::Hosted integrator class
10
+ class Hosted < Base
11
+
12
+ # Posts the execution json results in Xray
13
+ # @param results [String] Execution results
14
+ def send_execution_results(results)
15
+ Client.new(base_url).post('/rest/raven/1.0/import/execution', results.to_json, default_headers)
16
+ end
17
+
18
+ # Import Cucumber features files as a zip file via API
19
+ # @param project_key [String] JIRA's project key
20
+ # @param file_path [String] Cucumber features files' zip file
21
+ # @see https://confluence.xpand-it.com/display/public/XRAY/Importing+Cucumber+Tests+-+REST
22
+ def import_cucumber_tests(project_key, file_path)
23
+ headers = default_headers.merge({
24
+ multipart: true,
25
+ params: {
26
+ projectKey: project_key
27
+ }
28
+ })
29
+ payload = { file: File.new(file_path, 'rb') }
30
+
31
+ Client.new(base_url).post('/rest/raven/1.0/import/feature', payload, headers)
32
+ end
33
+
34
+ # Export Xray test scenarios to a zip file via API
35
+ # @param keys [String] test scenarios
36
+ # @param filter [String] project filter
37
+ # @see https://confluence.xpand-it.com/display/public/XRAY/Exporting+Cucumber+Tests+-+REST
38
+ def export_test_scenarios(keys, filter = nil)
39
+ params = {
40
+ keys: keys,
41
+ fz: true
42
+ }
43
+
44
+ params[:filter] = filter if filter.present?
45
+
46
+ headers = default_headers.merge(params: params)
47
+
48
+ RestClient.get("#{base_url}/rest/raven/1.0/export/test", headers)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,3 @@
1
+ require_relative 'publisher/base'
2
+ require_relative 'publisher/cloud'
3
+ require_relative 'publisher/hosted'
@@ -0,0 +1,112 @@
1
+ module QAT
2
+ module Reporter
3
+ class Xray
4
+ # namespace for test ids utility methods and objects
5
+ module Tests
6
+ # helper methods for test id manipulation
7
+ module Helpers
8
+ # Tags all untagged scenarios present in the test id report
9
+ #@param report [Tests::Report] test id report
10
+ def tag_untagged(report)
11
+ max_test_id = report.max_id
12
+ untagged = report.untagged
13
+
14
+ if untagged.any?
15
+ files = map_untagged(untagged)
16
+
17
+ announce_changes(files)
18
+
19
+ update_test_ids(files, max_test_id)
20
+ else
21
+ puts "There are no scenarios without test id. Last test id given was '@test##{max_test_id}'."
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # Returns all files containing untagged scenarios and their respective scenario line
28
+ #@param untagged [Array] list of untagged scenarios
29
+ #@return [Array]
30
+ def map_untagged(untagged)
31
+ files = {}
32
+
33
+ untagged.values.each do |file_colon|
34
+ file, colon = file_colon.split(':')
35
+ files[file] ||= []
36
+ files[file] << colon.to_i
37
+ end
38
+
39
+ files
40
+ end
41
+
42
+ # Announces to STDOUT the files that will be changed (test ids added)
43
+ #@param files [Array] list of files to change
44
+ #@see TestIds::Helpers#map_untaged
45
+ def announce_changes(files)
46
+ puts "Giving test ids to scenarios:"
47
+ puts files.to_json({
48
+ indent: ' ',
49
+ space: ' ',
50
+ object_nl: "\n"
51
+ })
52
+ end
53
+
54
+ # Rewrites the untagged files adding the missing test ids
55
+ #@param files [Array] list of files to change
56
+ #@param max_test_id [Integer] current max test id
57
+ def update_test_ids(files, max_test_id)
58
+ #iterate in file to give test id
59
+ begin
60
+ file_lines = []
61
+ files.each { |file, lines| max_test_id = rewrite_file(file, lines, max_test_id) }
62
+ rescue
63
+ path = File.join(Dir.pwd, 'public', 'test_ids_failed.feature')
64
+ puts "Tag attribution failed! Check '#{path}' for more information!"
65
+ File.write(path, file_lines.join)
66
+ end
67
+ end
68
+
69
+ # Rewrites the target file in the identified lines.
70
+ # Returns the max test id after the file update.
71
+ #@param file [String] file to rewrite
72
+ #@param lines [Array] lines to edit (add test id)
73
+ #@param max_test_id [Integer] current max test id
74
+ #@return [Integer]
75
+ def rewrite_file(file, lines, max_test_id)
76
+ norm_lines = lines.map { |line| line - 1 }
77
+ file_path = File.join(Dir.pwd, file)
78
+ file_lines = File.readlines(file_path)
79
+
80
+ norm_lines.size.times do
81
+ line = norm_lines.shift
82
+ puts "Editing file #{file} @ line #{line}."
83
+ max_test_id = add_tags(file_lines, line, max_test_id)
84
+ end
85
+
86
+ File.write(file_path, file_lines.join)
87
+
88
+ max_test_id
89
+ end
90
+
91
+ # Adds the test id tag to the identified line to edit
92
+ # Returns the max test id after the file update.
93
+ #@param file_lines [Array] Set of file lines
94
+ #@param line [Integer] index of line to edit
95
+ #@param max_test_id [Integer] current max test id
96
+ #@return [Integer]
97
+ def add_tags(file_lines, line, max_test_id)
98
+ if file_lines[line - 1].match(/^\s*@\w+/)
99
+ file_lines[line - 1] = " #{file_lines[line - 1].strip} @id:#{max_test_id += 1}\n"
100
+ else
101
+ file_lines[line] = " @id:#{max_test_id += 1}\n#{file_lines[line]}"
102
+ end
103
+
104
+ max_test_id
105
+ end
106
+
107
+ extend self
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end