# frozen_string_literal: true
require 'builder'
require 'cucumber/formatter/backtrace_filter'
require 'cucumber/formatter/io'
require 'cucumber/formatter/interceptor'
require 'fileutils'
require 'cucumber/formatter/ast_lookup'
module Cucumber
module Formatter
# The formatter used for --format junit
class Junit
include Io
class UnNamedFeatureError < StandardError
def initialize(feature_file)
super("The feature in '#{feature_file}' does not have a name. The JUnit XML format requires a name for the testsuite element.")
end
end
def initialize(config)
@ast_lookup = AstLookup.new(config)
config.on_event :test_case_started, &method(:on_test_case_started)
config.on_event :test_case_finished, &method(:on_test_case_finished)
config.on_event :test_step_finished, &method(:on_test_step_finished)
config.on_event :test_run_finished, &method(:on_test_run_finished)
@reportdir = ensure_dir(config.out_stream, 'junit')
@config = config
@features_data = Hash.new do |h, k|
h[k] = {
feature: nil,
failures: 0,
errors: 0,
tests: 0,
skipped: 0,
time: 0,
builder: Builder::XmlMarkup.new(indent: 2)
}
end
end
def on_test_case_started(event)
test_case = event.test_case
start_feature(test_case) unless same_feature_as_previous_test_case?(test_case)
@failing_test_step = nil
# In order to fill out and , we need to
# intercept the $stderr and $stdout
@interceptedout = Interceptor::Pipe.wrap(:stdout)
@interceptederr = Interceptor::Pipe.wrap(:stderr)
end
def on_test_step_finished(event)
test_step, result = *event.attributes
return if @failing_test_step
@failing_test_step = test_step unless result.ok?(@config.strict)
end
def on_test_case_finished(event)
test_case, result = *event.attributes
result = result.with_filtered_backtrace(Cucumber::Formatter::BacktraceFilter)
test_case_name = NameBuilder.new(test_case, @ast_lookup)
scenario = test_case_name.scenario_name
scenario_designation = "#{scenario}#{test_case_name.name_suffix}"
output = create_output_string(test_case, scenario, result, test_case_name.row_name)
build_testcase(result, scenario_designation, output)
Interceptor::Pipe.unwrap! :stdout
Interceptor::Pipe.unwrap! :stderr
end
def on_test_run_finished(_event)
@features_data.each { |_file, data| end_feature(data) }
end
private
def same_feature_as_previous_test_case?(test_case)
@current_feature_data && @current_feature_data[:uri] == test_case.location.file
end
def start_feature(test_case)
uri = test_case.location.file
feature = @ast_lookup.gherkin_document(uri)[:feature]
raise UnNamedFeatureError, uri if feature[:name].empty?
@current_feature_data = @features_data[uri]
@current_feature_data[:uri] = uri unless @current_feature_data[:uri]
@current_feature_data[:feature] = feature unless @current_feature_data[:feature]
end
def end_feature(feature_data)
@testsuite = Builder::XmlMarkup.new(indent: 2)
@testsuite.instruct!
@testsuite.testsuite(
failures: feature_data[:failures],
errors: feature_data[:errors],
skipped: feature_data[:skipped],
tests: feature_data[:tests],
time: format('%.6f', feature_data[:time]),
name: feature_data[:feature][:name]
) do
@testsuite << feature_data[:builder].target!
end
write_file(feature_result_filename(feature_data[:uri]), @testsuite.target!)
end
def create_output_string(test_case, scenario, result, row_name) # rubocop:disable Metrics/PerceivedComplexity
scenario_source = @ast_lookup.scenario_source(test_case)
keyword = scenario_source.type == :Scenario ? scenario_source.scenario[:keyword] : scenario_source.scenario_outline[:keyword]
output = "#{keyword}: #{scenario}\n\n"
return output if result.ok?(@config.strict)
if scenario_source.type == :Scenario
if @failing_test_step
if @failing_test_step.hook?
output += "#{@failing_test_step.text} at #{@failing_test_step.location}\n"
else
step_source = @ast_lookup.step_source(@failing_test_step).step
output += "#{step_source[:keyword]}#{@failing_test_step.text}\n"
end
else # An Around hook has failed
output += "Around hook\n"
end
else
output += "Example row: #{row_name}\n"
end
output + "\nMessage:\n"
end
def build_testcase(result, scenario_designation, output)
duration = ResultBuilder.new(result).test_case_duration
@current_feature_data[:time] += duration
classname = @current_feature_data[:feature][:name]
name = scenario_designation
@current_feature_data[:builder].testcase(classname: classname, name: name, time: format('%.6f', duration)) do
if !result.passed? && result.ok?(@config.strict)
@current_feature_data[:builder].skipped
@current_feature_data[:skipped] += 1
elsif !result.passed?
status = result.to_sym
exception = get_backtrace_object(result)
@current_feature_data[:builder].failure(message: "#{status} #{name}", type: status) do
@current_feature_data[:builder].cdata! output
@current_feature_data[:builder].cdata!(format_exception(exception)) if exception
end
@current_feature_data[:failures] += 1
end
@current_feature_data[:builder].tag!('system-out') do
@current_feature_data[:builder].cdata! strip_control_chars(@interceptedout.buffer_string)
end
@current_feature_data[:builder].tag!('system-err') do
@current_feature_data[:builder].cdata! strip_control_chars(@interceptederr.buffer_string)
end
end
@current_feature_data[:tests] += 1
end
def get_backtrace_object(result)
if result.failed?
result.exception
elsif result.backtrace
result
end
end
def format_exception(exception)
(["#{exception.message} (#{exception.class})"] + exception.backtrace).join("\n")
end
def feature_result_filename(feature_file)
File.join(@reportdir, "TEST-#{basename(feature_file)}.xml")
end
def basename(feature_file)
File.basename(feature_file.gsub(/[\\\/]/, '-'), '.feature') # rubocop:disable Style/RegexpLiteral
end
def write_file(feature_filename, data)
File.open(feature_filename, 'w') { |file| file.write(data) }
end
# strip control chars from cdata, to make it safe for external parsers
def strip_control_chars(cdata)
cdata.scan(/[[:print:]\t\n\r]/).join
end
end
class NameBuilder
attr_reader :scenario_name, :name_suffix, :row_name
def initialize(test_case, ast_lookup)
@name_suffix = ''
@row_name = ''
scenario_source = ast_lookup.scenario_source(test_case)
if scenario_source.type == :Scenario
scenario(scenario_source.scenario)
else
scenario_outline(scenario_source.scenario_outline)
examples_table_row(scenario_source.row)
end
end
def scenario(scenario)
@scenario_name = scenario[:name].empty? ? 'Unnamed scenario' : scenario[:name]
end
def scenario_outline(outline)
@scenario_name = outline[:name].empty? ? 'Unnamed scenario outline' : outline[:name]
end
def examples_table_row(row)
@row_name = '| ' + row[:cells].map { |cell| cell[:value] }.join(' | ') + ' |'
@name_suffix = " (outline example : #{@row_name})"
end
end
class ResultBuilder
attr_reader :test_case_duration
def initialize(result)
@test_case_duration = 0
result.describe_to(self)
end
def passed(*) end
def failed(*) end
def undefined(*) end
def skipped(*) end
def pending(*) end
def exception(*) end
def duration(duration, *)
duration.tap { |dur| @test_case_duration = dur.nanoseconds / 10**9.0 }
end
end
end
end