diff --git a/lib/report_portal/cucumber/extractor.rb b/lib/report_portal/cucumber/extractor.rb new file mode 100644 index 0000000..9f4e89d --- /dev/null +++ b/lib/report_portal/cucumber/extractor.rb @@ -0,0 +1,140 @@ +module ReportPortal + module Cucumber + # extract interesting data from a cucumber v4+ event + class ExtractorCucumber4 + def initialize(config) + require 'cucumber/formatter/ast_lookup' + @ast_lookup = ::Cucumber::Formatter::AstLookup.new(config) + end + + # For Cucumber4 we actually return a representation of the gherkin + # document. So when querying tags, keywords etc, these need to come + # from the 'feature' attribute of the gherkin document + def feature(test_case) + @ast_lookup.gherkin_document(test_case.location.file) + end + + def feature_location(gherkin) + gherkin.uri + end + + def feature_tags(gherkin) + gherkin.feature.tags + end + + def feature_name(gherkin) + "#{gherkin.feature.keyword}: #{gherkin.feature.name}" + end + + def same_feature_as_previous_test_case?(previous_name, gherkin) + previous_name == gherkin.uri.split(File::SEPARATOR).last + end + + def scenario_keyword(test_case) + @ast_lookup.scenario_source(test_case).scenario.keyword + end + + def scenario_name(test_case) + @ast_lookup.scenario_source(test_case).scenario.name + end + + def step_source(test_step) + @ast_lookup.step_source(test_step).step + end + + def step_multiline_arg(test_step) + test_step.multiline_arg + end + + def step_backtrace_line(test_step) + test_step.backtrace_line + end + + def step_type(test_step) + case step?(test_step) + when true + 'Step' + when false + "#{test_step.text} at #{test_step.location}" + end + end + + def step?(test_step) + !test_step.hook? + end + end + + # extract interesting data from a cucumber v3 event + class ExtractorCucumber3 + def initialize(*) + require 'cucumber/formatter/hook_query_visitor' + end + + def feature(test_case) + test_case.feature + end + + def feature_location(feature) + feature.location.file + end + + def feature_tags(feature) + feature.tags + end + + def feature_name(feature) + "#{feature.keyword}: #{feature.name}" + end + + def same_feature_as_previous_test_case?(previous_name, feature) + previous_name == feature.location.file.split(File::SEPARATOR).last + end + + def scenario_keyword(test_case) + test_case.keyword + end + + def scenario_name(test_case) + test_case.name + end + + def step_source(test_step) + test_step.source.last + end + + def step_multiline_arg(test_step) + step_source(test_step).multiline_arg + end + + def step_backtrace_line(test_step) + test_step.source.last.backtrace_line + end + + def step_type(test_step) + case step?(test_step) + when true + 'Step' + when false + hook_class_name = test_step.source.last.class.name.split('::').last + "#{hook_class_name} at #{test_step.location}" + end + end + + def step?(test_step) + !::Cucumber::Formatter::HookQueryVisitor.new(test_step).hook? + end + end + + # Simple factory to provide an implementation that can extract interesting + # data from a cucumber event + class Extractor + def self.create(config) + if (::Cucumber::VERSION.split('.').map(&:to_i) <=> [4, 0, 0]).positive? + ExtractorCucumber4.new(config) + else + ExtractorCucumber3.new(config) + end + end + end + end +end diff --git a/lib/report_portal/cucumber/formatter.rb b/lib/report_portal/cucumber/formatter.rb index 8c4d13b..c15acf5 100644 --- a/lib/report_portal/cucumber/formatter.rb +++ b/lib/report_portal/cucumber/formatter.rb @@ -1,4 +1,5 @@ require_relative 'report' +require_relative 'extractor' module ReportPortal module Cucumber @@ -6,10 +7,19 @@ class Formatter # @api private def initialize(config) ENV['REPORT_PORTAL_USED'] = 'true' + @config = config + # Helper class used to abstract away the internal + # differences of cucumber 3 / 4+ + @extractor = Extractor.create(config) setup_message_processing - @io = config.out_stream + case config.out_stream + when IO + @io = config.out_stream + when String + @io = File.open(config.out_stream, 'w') + end %i[test_case_started test_case_finished test_step_started test_step_finished test_run_finished].each do |event_name| config.on_event event_name do |event| @@ -21,18 +31,24 @@ def initialize(config) def puts(message) process_message(:puts, message) - @io.puts(message) - @io.flush + @io&.puts(message) + @io&.flush end def embed(*args) process_message(:embed, *args) end + # embed is deprecated from cucumber4, should use attach + # instead + def attach(*args) + process_message(:attach, *args) + end + private def report - @report ||= ReportPortal::Cucumber::Report.new + @report ||= ReportPortal::Cucumber::Report.new(@extractor) end def setup_message_processing diff --git a/lib/report_portal/cucumber/report.rb b/lib/report_portal/cucumber/report.rb index f331c2e..723d865 100644 --- a/lib/report_portal/cucumber/report.rb +++ b/lib/report_portal/cucumber/report.rb @@ -1,5 +1,4 @@ require 'cucumber/formatter/io' -require 'cucumber/formatter/hook_query_visitor' require 'tree' require 'securerandom' @@ -18,7 +17,8 @@ def attach_to_launch? ReportPortal::Settings.instance.formatter_modes.include?('attach_to_launch') end - def initialize + def initialize(extractor) + @extractor = extractor @last_used_time = 0 @root_node = Tree::TreeNode.new('') @parent_item_node = @root_node @@ -45,13 +45,13 @@ def start_launch(desired_time = ReportPortal.now) # TODO: time should be a required argument def test_case_started(event, desired_time = ReportPortal.now) test_case = event.test_case - feature = test_case.feature - if report_hierarchy? && !same_feature_as_previous_test_case?(feature) + feature = @extractor.feature(test_case) + if report_hierarchy? && !@extractor.same_feature_as_previous_test_case?(@parent_item_node.name, feature) end_feature(desired_time) unless @parent_item_node.is_root? start_feature_with_parentage(feature, desired_time) end - name = "#{test_case.keyword}: #{test_case.name}" + name = "#{@extractor.scenario_keyword(test_case)}: #{@extractor.scenario_name(test_case)}" description = test_case.location.to_s tags = test_case.tags.map(&:name) type = :STEP @@ -76,13 +76,14 @@ def test_case_finished(event, desired_time = ReportPortal.now) def test_step_started(event, desired_time = ReportPortal.now) test_step = event.test_step - if step?(test_step) # `after_test_step` is also invoked for hooks - step_source = test_step.source.last + if @extractor.step?(test_step) # `after_test_step` is also invoked for hooks + step_source = @extractor.step_source(test_step) message = "-- #{step_source.keyword}#{step_source.text} --" - if step_source.multiline_arg.doc_string? - message << %(\n"""\n#{step_source.multiline_arg.content}\n""") - elsif step_source.multiline_arg.data_table? - message << step_source.multiline_arg.raw.reduce("\n") { |acc, row| acc << "| #{row.join(' | ')} |\n" } + multiline_arg = @extractor.step_multiline_arg(test_step) + if multiline_arg.doc_string? + message << %(\n"""\n#{multiline_arg.content}\n""") + elsif multiline_arg.data_table? + message << multiline_arg.raw.reduce("\n") { |acc, row| acc << "| #{row.join(' | ')} |\n" } end ReportPortal.send_log(:trace, message, time_to_send(desired_time)) end @@ -98,20 +99,14 @@ def test_step_finished(event, desired_time = ReportPortal.now) ex = result.exception format("%s: %s\n %s", ex.class.name, ex.message, ex.backtrace.join("\n ")) else - format("Undefined step: %s:\n%s", test_step.text, test_step.source.last.backtrace_line) + format("Undefined step: %s:\n%s", test_step.text, @extractor.step_backtrace_line(test_step)) end ReportPortal.send_log(:error, exception_info, time_to_send(desired_time)) end if status != :passed log_level = status == :skipped ? :warn : :error - step_type = if step?(test_step) - 'Step' - else - hook_class_name = test_step.source.last.class.name.split('::').last - location = test_step.location - "#{hook_class_name} at `#{location}`" - end + step_type = @extractor.step_type(test_step) ReportPortal.send_log(log_level, "#{step_type} #{status}", time_to_send(desired_time)) end end @@ -134,6 +129,17 @@ def embed(path_or_src, mime_type, label, desired_time = ReportPortal.now) ReportPortal.send_file(:info, path_or_src, label, time_to_send(desired_time), mime_type) end + def attach(path_or_src, mime_type, desired_time = ReportPortal.now) + # Cucumber > 4 has deprecated the use of puts, and instead wants + # the use of "log". This in turn calls attach on all formatters + # with mime-type 'text/x.cucumber.log+plain' + if mime_type == 'text/x.cucumber.log+plain' + ReportPortal.send_log(:info, path_or_src, time_to_send(desired_time)) + else + ReportPortal.send_file(:info, path_or_src, nil, time_to_send(desired_time), mime_type) + end + end + private # Report Portal sorts logs by time. However, several logs might have the same time. @@ -150,14 +156,10 @@ def time_to_send(desired_time) @last_used_time = time_to_send end - def same_feature_as_previous_test_case?(feature) - @parent_item_node.name == feature.location.file.split(File::SEPARATOR).last - end - def start_feature_with_parentage(feature, desired_time) parent_node = @root_node child_node = nil - path_components = feature.location.file.split(File::SEPARATOR) + path_components = @extractor.feature_location(feature).split(File::SEPARATOR) path_components.each_with_index do |path_component, index| child_node = parent_node[path_component] unless child_node # if child node was not created yet @@ -167,15 +169,15 @@ def start_feature_with_parentage(feature, desired_time) tags = [] type = :SUITE else - name = "#{feature.keyword}: #{feature.name}" - description = feature.file # TODO: consider adding feature description and comments - tags = feature.tags.map(&:name) + name = @extractor.feature_name(feature) + description = @extractor.feature_location(feature) # TODO: consider adding feature description and comments + tags = @extractor.feature_tags(feature).map(&:name) type = :TEST end # TODO: multithreading # Parallel formatter always executes scenarios inside the same feature in the same process - if parallel? && + if (parallel? || attach_to_launch?) && index < path_components.size - 1 && # is folder? - (id_of_created_item = ReportPortal.item_id_of(name, parent_node)) # get id for folder from report portal + (id_of_created_item = ReportPortal.uuid_of(name, parent_node)) # get id for folder from report portal # get child id from other process item = ReportPortal::TestItem.new(name: name, type: type, id: id_of_created_item, start_time: time_to_send(desired_time), description: description, closed: false, tags: tags) child_node = Tree::TreeNode.new(path_component, item) @@ -208,10 +210,6 @@ def close_all_children_of(root_node) end end - def step?(test_step) - !::Cucumber::Formatter::HookQueryVisitor.new(test_step).hook? - end - def report_hierarchy? !ReportPortal::Settings.instance.formatter_modes.include?('skip_reporting_hierarchy') end diff --git a/lib/report_portal/http_client.rb b/lib/report_portal/http_client.rb index 55cd64c..d3f84a1 100644 --- a/lib/report_portal/http_client.rb +++ b/lib/report_portal/http_client.rb @@ -38,7 +38,7 @@ def create_client def add_insecure_ssl_options ssl_context = OpenSSL::SSL::SSLContext.new ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE - @http.default_options = { ssl_context: ssl_context } + @http.default_options = @http.default_options.with_ssl_context(ssl_context) end # Response should be consumed before sending next request via the same persistent connection. diff --git a/lib/reportportal.rb b/lib/reportportal.rb index 4315e95..84ea2f8 100644 --- a/lib/reportportal.rb +++ b/lib/reportportal.rb @@ -63,9 +63,9 @@ def finish_item(item, status = nil, end_time = nil, force_issue = nil) data = { end_time: end_time.nil? ? now : end_time } data[:status] = status unless status.nil? if force_issue && status != :passed # TODO: check for :passed status is probably not needed - data[:issue] = { issue_type: 'AUTOMATION_BUG', comment: force_issue.to_s } + data[:issue] = { issue_type: 'ab001', comment: force_issue.to_s } elsif status == :skipped - data[:issue] = { issue_type: 'NOT_ISSUE' } + data[:issue] = { issue_type: 'nb001' } end send_request(:put, "item/#{item.id}", json: data) item.closed = true @@ -126,7 +126,7 @@ def delete_items(item_ids) # needed for parallel formatter def item_id_of(name, parent_node) path = if parent_node.is_root? # folder without parent folder - "item?filter.eq.launch=#{@launch_id}&filter.eq.name=#{CGI.escape(name)}&filter.size.path=0" + "item?filter.eq.launchId=#{launch_id_to_number}&filter.eq.name=#{CGI.escape(name)}" else "item?filter.eq.parent=#{parent_node.content.id}&filter.eq.name=#{CGI.escape(name)}" end @@ -136,12 +136,30 @@ def item_id_of(name, parent_node) end end + # needed for parallel formatter + def uuid_of(name, parent_node) + itemid = item_id_of(name, parent_node) + return nil if itemid.nil? + + path = "item/#{itemid}" + data = send_request(:get, path) + + data.key?('uuid') ? data['uuid'] : nil + end + + # needed for parallel formatter + def launch_id_to_number + path = "launch/#{@launch_id}" + data = send_request(:get, path) + data['id'] + end + # needed for parallel formatter def close_child_items(parent_id) path = if parent_id.nil? - "item?filter.eq.launch=#{@launch_id}&filter.size.path=0&page.page=1&page.size=100" + "item?filter.eq.launchId=#{launch_id_to_number}" else - "item?filter.eq.parent=#{parent_id}&page.page=1&page.size=100" + "item?filter.eq.launchId=#{launch_id_to_number}&filter.eq.parentId=#{parent_id}&page.page=1&page.size=100" end ids = [] loop do @@ -153,14 +171,22 @@ def close_child_items(parent_id) url = nil end data['content'].each do |i| - ids << i['id'] if i['has_childs'] && i['status'] == 'IN_PROGRESS' + ids << i['id'] if i['hasChildren'] && i['status'] == 'IN_PROGRESS' end break if url.nil? end + # There's a mix of numerical ID and string UUID going on here in the API + # When querying child items here, we need to use the numerical id, but when + # we call finish_item, the API it calls wants the UUID. Simplify by creating + # a map. + ids = ids.map do |id| + { id: id, uuid: send_request(:get, "item/#{id}")['uuid'] } + end + ids.each do |id| - close_child_items(id) - finish_item(TestItem.new(id: id)) + close_child_items(id[:id]) + finish_item(TestItem.new(id: id[:uuid])) end end