#encoding: UTF-8
$LOAD_PATH << File.dirname(__FILE__)
require 'progressbar'
require 'color_helper'
require 'thread'
require 'rexml/document'
include REXML
include ColorHelpers
include Interactive
class Rspec_parallel
attr_reader :case_number
attr_reader :failure_number
attr_reader :pending_number
attr_reader :case_info_list
attr_reader :interrupted
attr_accessor :thread_number
attr_accessor :max_rerun_times
attr_accessor :max_thread_number
def initialize(options = {})
@options = {:thread_number => 4, :case_folder => "./spec/", :report_folder => "./reports/",
:filter => {}, :env_list => [], :show_pending => false, :rerun => false,
:separate_rerun_report => true, :max_rerun_times => 10, :max_thread_number => 16}.merge(options)
@thread_number = @options[:thread_number]
@max_rerun_times = @options[:max_rerun_times]
@max_thread_number = @options[:max_thread_number]
@case_number = 0
@failure_number = 0
@pending_number = 0
@interrupted = false
end
def run_tests()
start_time = Time.now # timer of rspec task
@queue = Queue.new # store all tests to run
@case_info_list = [] # store results of all tests
@lock = Mutex.new # use lock to avoid output mess up
if @thread_number < 1
puts red("thread_number can't be less than 1")
exit(1)
elsif @thread_number > @max_thread_number
puts red("thread_number can't be greater than #{@max_thread_number}")
return
end
puts yellow("threads number: #{@thread_number}\n")
rerun = @options[:rerun]
separate_rerun_report = @options[:separate_rerun_report]
if rerun && separate_rerun_report
@report_folder = get_rerun_folder(true)
if @report_folder.include? "rerun#{@max_rerun_times + 1}"
puts yellow("rerun task has been executed for #{@max_rerun_times}" +
" times, maybe you should start a new run")
exit(1)
end
else
@report_folder = @options[:report_folder]
end
filter = @options[:filter]
if rerun
get_failed_cases
else
parse_case_list(filter)
end
if @queue.empty?
puts yellow("no cases to run, exit.")
return
end
pbar = ProgressBar.new("0/#{@queue.size}", @queue.size, $stdout)
pbar.format_arguments = [:title, :percentage, :bar, :stat]
failure_list = []
pending_list = []
Thread.abort_on_exception = false
threads = []
@thread_number.times do |i|
threads << Thread.new do
until @queue.empty?
task = @queue.pop
env_extras = {}
env_list = @options[:env_list]
if env_list && env_list[i]
env_extras = env_list[i]
end
t1 = Time.now
task_output = run_task(task, env_extras)
t2 = Time.now
case_info = parse_case_log(task_output)
unless case_info
puts task_output
next
end
case_info['duration'] = t2 - t1
@case_info_list << case_info
if case_info['status'] == 'fail'
@lock.synchronize do
@failure_number += 1
failure_list << case_info
# print failure immediately during the execution
$stdout.print "\e[K"
if @failure_number == 1
$stdout.print "Failures:\n\n"
end
puts " #{@failure_number}) #{case_info['test_name']}"
$stdout.print "#{red(case_info['error_message'])}"
$stdout.print "#{cyan(case_info['error_stack_trace'])}"
$stdout.print red(" (Failure time: #{Time.now})\n\n")
end
elsif case_info['status'] == 'pending'
@lock.synchronize do
@pending_number += 1
pending_list << case_info
end
end
@case_number += 1
pbar.inc
pbar.instance_variable_set("@title", "#{pbar.current}/#{pbar.total}")
end
end
# ramp up user threads one by one
sleep 0.1
end
begin
threads.each { |t| t.join }
rescue Interrupt
puts yellow("catch Ctrl+C, will exit gracefully")
@interrupted = true
end
pbar.finish
# print pending cases if configured
show_pending = @options[:show_pending]
if show_pending && @pending_number > 0
$stdout.print "\n"
puts "Pending:"
pending_list.each {|case_info|
puts " #{yellow(case_info['test_name'])}\n"
$stdout.print cyan("#{case_info['pending_info']}")
}
end
# print total time and summary result
end_time = Time.now
puts "\nFinished in #{format_time(end_time-start_time)}\n"
if @failure_number > 0
$stdout.print red("#{@case_number} examples, #{@failure_number} failures")
$stdout.print red(", #{@pending_number} pending") if @pending_number > 0
elsif @pending_number > 0
$stdout.print yellow("#{@case_number} examples, #{@failure_number} failures, #{@pending_number} pending")
else
$stdout.print green("#{@case_number} examples, 0 failures")
end
$stdout.print "\n"
# print rerun command of failed examples
unless failure_list.empty?
$stdout.print "\nFailed examples:\n\n"
failure_list.each do |case_info|
$stdout.print red(case_info['rerun_cmd'].split(' # ')[0])
$stdout.print cyan(" # #{case_info['test_name']}\n")
end
end
generate_reports(end_time - start_time, rerun && !separate_rerun_report)
end
def get_case_list
case_folder = @options[:case_folder]
file_list = `grep -rl '' #{case_folder}`
case_list = []
file_list.each_line { |filename|
unless filename.include? "_spec.rb"
next
end
f = File.read(filename.strip).force_encoding("ISO-8859-1").encode("utf-8", replace: nil)
# try to get tags of describe level
describe_text = f.scan(/describe [\s\S]*? do/)[0]
describe_tags = []
temp = describe_text.scan(/[,\s]:(\w+)/)
unless temp == nil
temp.each do |t|
describe_tags << t[0]
end
end
# get cases of normal format: "it ... do"
cases = f.scan(/(it (["'])([\s\S]*?)\2[\s\S]*? do)/)
line_number = 0
if cases
cases.each { |c1|
c = c1[0]
tags = []
draft_tags = c.scan(/[,\s]:(\w+)/)
draft_tags.each { |tag|
tags << tag[0]
}
tags += describe_tags
tags.uniq
i = 0
cross_line = false
f.each_line { |line|
i += 1
if i <= line_number && line_number > 0
next
end
if line.include? c1[2]
if line.strip.end_with? " do"
case_hash = {"line" => "#{filename.strip}:#{i}", "tags" => tags}
case_list << case_hash
line_number = i
cross_line = false
break
else
cross_line = true
end
end
if cross_line && (line.strip.end_with? " do")
case_hash = {"line" => "#{filename.strip}:#{i}", "tags" => tags}
case_list << case_hash
line_number = i
cross_line = false
break
end
}
}
end
# get cases of another format: "it {...}"
cases = f.scan(/it \{[\s\S]*?\}/)
line_number = 0
if cases
cases.each { |c|
i = 0
f.each_line { |line|
i += 1
if i <= line_number && line_number > 0
next
end
if line.include? c
case_hash = {"line" => "#{filename.strip}:#{i}", "tags" => describe_tags}
case_list << case_hash
line_number = i
break
end
}
}
end
}
case_list
end
def parse_case_list(filter)
all_case_list = get_case_list
pattern_filter_list = []
tags_filter_list = []
if filter["pattern"]
all_case_list.each { |c|
if c["line"].match(filter["pattern"])
pattern_filter_list << c
end
}
else
pattern_filter_list = all_case_list
end
if filter["tags"]
include_tags = []
exclude_tags = []
all_tags = filter["tags"].split(",")
all_tags.each { |tag|
if tag.start_with? "~"
exclude_tags << tag.gsub("~", "")
else
include_tags << tag
end
}
pattern_filter_list.each { |c|
if (include_tags.length == 0 || (c["tags"] - include_tags).length < c["tags"].length) &&
((c["tags"] - exclude_tags).length == c["tags"].length)
tags_filter_list << c
end
}
else
tags_filter_list = pattern_filter_list
end
tags_filter_list = reorder_tests(tags_filter_list)
tags_filter_list.each { |t|
@queue << t["line"]
}
end
def get_rerun_folder(get_next=false)
rerun_folder = @options[:report_folder]
i = @max_rerun_times
while(i > 0)
if File.exists? File.join(rerun_folder, "rerun#{i}")
if get_next
rerun_folder = File.join(rerun_folder, "rerun#{i + 1}")
else
rerun_folder = File.join(rerun_folder, "rerun#{i}")
end
break
end
i -= 1
end
if get_next && (rerun_folder.include? "rerun") == false
rerun_folder = File.join(rerun_folder, 'rerun1')
end
rerun_folder
end
def get_failed_cases
if @options[:separate_rerun_report]
last_report_folder = get_rerun_folder
last_report_file_path = File.join(last_report_folder, "junitResult.xml")
else
last_report_file_path = File.join(@report_folder, "junitResult.xml")
end
unless File.exists? last_report_file_path
puts yellow("can't find result of last run")
exit(1)
end
report_file = File.new(last_report_file_path)
begin
@doc = REXML::Document.new report_file
rescue
puts red("invalid format of report xml")
exit(1)
end
@doc.elements.each("result/suites/suite/cases/case") do |c|
if c.get_elements("errorDetails")[0]
rerun_cmd = c.get_elements("rerunCommand")[0].text
line = rerun_cmd.split('#')[0].gsub('rspec ', '').strip
@queue << line
end
end
end
def run_task(task, env_extras)
cmd = [] # Preparing command for popen
cmd << ENV.to_hash.merge(env_extras)
cmd += ["bundle", "exec", "rspec", "-f", "d", "--color", task]
cmd
output = ""
IO.popen(cmd, :err => [:child, :out]) do |io|
output << io.read
end
output
end
def reorder_tests(case_list)
return case_list
end
def format_time(t)
time_str = ''
time_str += (t / 3600).to_i.to_s + " hours " if t > 3600
time_str += (t % 3600 / 60).to_i.to_s + " minutes " if t > 60
time_str += (t % 60).to_f.round(2).to_s + " seconds"
time_str
end
def parse_case_log(str)
return nil if str =~ /0 examples/
result = {}
logs = []
str.each_line {|l| logs << l}
return nil if logs == []
stderr = ''
unless logs[0].start_with? 'Run options:'
clear_logs = []
logs_start = false
for i in 0..logs.length-1
if logs[i].strip.start_with? 'Run options:'
logs_start = true
end
if logs_start
clear_logs << logs[i]
else
stderr += logs[i]
end
end
logs = clear_logs
end
result['stderr'] = stderr
stdout = ''
if logs[4].strip != ''
clear_logs = []
stdout_start = true
for i in 0..logs.length-1
if i < 3
clear_logs << logs[i]
elsif stdout_start && logs[i+1].strip == ''
clear_logs << logs[i]
stdout_start = false
elsif !stdout_start
clear_logs << logs[i]
else
stdout += logs[i]
end
end
logs = clear_logs
end
result['stdout'] = stdout
result['class_name'] = logs[2].strip
result['test_desc'] = logs[3].gsub(/\((FAILED|PENDING).+\)/, '').strip
result['test_name'] = result['class_name'] + ' ' + result['test_desc']
if logs[-1].include? '1 pending'
result['status'] = 'pending'
pending_info = ''
for i in 7..logs.length-4
next if logs[i].strip == ''
pending_info += logs[i]
end
result['pending_info'] = pending_info
elsif logs[-1].include? '0 failures'
result['status'] = 'pass'
elsif logs[-1].start_with? 'rspec '
result['status'] = 'fail'
result['rerun_cmd'] = logs[-1]
error_message = logs[8]
error_stack_trace = ''
for i in 9..logs.length-8
next if logs[i].strip == ''
if logs[i].strip.start_with? '# '
error_stack_trace += logs[i]
else
error_message += logs[i]
end
end
error_message.each_line do |l|
next if l.include? 'Error:'
result['error_details'] = l.strip
break
end
if error_message.index(result['error_details']) < error_message.length - result['error_details'].length - 10
result['error_details'] += "..."
end
result['error_message'] = error_message
result['error_stack_trace'] = error_stack_trace
else
result['status'] = 'unknown'
end
result
end
def generate_reports(time, update_report)
%x[mkdir #{@report_folder}] unless File.exists? @report_folder
@summary_report = ""
@summary_report += "\n"
@summary_report += "\n"
@summary_report += "\n"
class_name_list = []
@case_info_list.each do |case_info|
class_name_list << case_info['class_name']
end
class_name_list.uniq!
class_name_list.sort!
class_name_list.each do |class_name|
temp_case_info_list = []
@case_info_list.each do |case_info|
if case_info['class_name'] == class_name
temp_case_info_list << case_info
end
end
generate_single_file_report(temp_case_info_list)
end
if update_report
update_ci_report
end
@summary_report += "\n"
@summary_report += "#{time}\n"
@summary_report += "false\n"
@summary_report += "\n"
report_file_path = File.join(@report_folder, 'junitResult.xml')
fr = File.new(report_file_path, 'w')
if update_report
fr.puts @doc
else
fr.puts @summary_report
end
fr.close
end
def update_ci_report
@doc.elements.each("result/suites/suite/cases/case") do |c1|
if c1.get_elements("errorDetails")[0]
test_name = c1.get_elements("testName")[0].text
@case_info_list.each do |c2|
if test_name == c2['test_name'].encode({:xml => :attr})
c1.get_elements("duration")[0].text = c2['duration']
if c2['status'] == 'fail'
text = c2['error_message'].gsub('Failure/Error: ', '') + "\n"
text += c2['error_stack_trace'].gsub('# ', '')
c1.get_elements("errorStackTrace")[0].text = text
c1.get_elements("errorDetails")[0].text = c2['error_details']
c1.get_elements("rerunCommand")[0].text = c2['rerun_cmd']
else
c1.delete c1.get_elements("errorDetails")[0]
c1.delete c1.get_elements("errorStackTrace")[0]
c1.delete c1.get_elements("rerunCommand")[0]
end
break
end
end
end
end
end
def generate_single_file_report(case_info_list)
return if case_info_list == []
class_name = case_info_list[0]['class_name']
file_name = File.join(@report_folder, class_name.gsub(/:+/, '-') + '.xml')
name = class_name.gsub(':', '_')
suite_duration = 0.0
fail_num = 0
error_num = 0
pending_num = 0
stdout = ''
stderr = ''
stdout_list = []
stderr_list = []
case_desc_list = []
case_info_list.each do |case_info|
suite_duration += case_info['duration']
stdout_list << case_info['stdout']
stderr_list << case_info['stderr']
case_desc_list << case_info['test_desc']
if case_info['status'] == 'fail'
if case_info['error_message'].include? "expect"
fail_num += 1
else
error_num += 1
end
elsif case_info['status'] == 'pending'
pending_num += 1
end
end
stdout_list.uniq!
stderr_list.uniq!
case_desc_list.sort!
stdout_list.each {|s| stdout += s}
stderr_list.each {|s| stderr += s}
@summary_report += "\n"
@summary_report += "#{file_name}\n"
@summary_report += "#{name}\n"
@summary_report += "\n"
@summary_report += stdout.encode({:xml => :text}) if stdout.length > 0
@summary_report += "\n"
@summary_report += "\n"
@summary_report += stderr.encode({:xml => :text}) if stderr.length > 0
@summary_report += "\n"
@summary_report += "#{suite_duration}\n"
@summary_report += "\n"
ff = File.new(file_name, 'w')
ff.puts ''
ff.puts ""
case_desc_list.each do |case_desc|
i = case_info_list.index {|c| c['test_desc'] == case_desc}
case_info = case_info_list[i]
test_name = case_info['test_name']
test_name += " (PENDING)" if case_info['status'] == 'pending'
test_name = test_name.encode({:xml => :attr})
@summary_report += "\n"
@summary_report += "#{case_info['duration']}\n"
@summary_report += "#{case_info['class_name']}\n"
@summary_report += "#{test_name}\n"
@summary_report += "#{case_info['status'] == 'pending'}\n"
ff.puts ""
ff.puts "" if case_info['status'] == 'pending'
if case_info['status'] == 'fail'
@summary_report += "\n"
@summary_report += case_info['error_message'].encode({:xml => :text}).gsub('Failure/Error: ', '')
@summary_report += case_info['error_stack_trace'].encode({:xml => :text}).gsub('# ', '')
@summary_report += "\n"
@summary_report += "\n"
@summary_report += case_info['error_details'].encode({:xml => :text})
@summary_report += "\n"
@summary_report += "#{case_info['rerun_cmd'].encode({:xml => :text})}\n"
if case_info['error_message'].include? "expected"
type = "RSpec::Expectations::ExpectationNotMetError"
elsif case_info['error_message'].include? "RuntimeError"
type = "RuntimeError"
else
type = "UnknownError"
end
ff.puts " :attr})}>"
ff.puts case_info['error_message'].encode({:xml => :text}).gsub('Failure/Error: ', '')
ff.puts case_info['error_stack_trace'].encode({:xml => :text}).gsub('# ', '')
ff.puts ""
ff.puts "#{case_info['rerun_cmd'].encode({:xml => :text})}"
end
@summary_report += "0\n"
@summary_report += "\n"
ff.puts ""
end
@summary_report += "\n"
@summary_report += ""
ff.puts ""
ff.puts stdout.encode({:xml => :text}) if stdout.length > 0
ff.puts ""
ff.puts ""
ff.puts stderr.encode({:xml => :text}) if stderr.length > 0
ff.puts ""
ff.puts ""
ff.close
end
end