1#!/usr/bin/ruby
2#
3# unity_to_junit.rb
4#
5require 'fileutils'
6require 'optparse'
7require 'ostruct'
8require 'set'
9
10require 'pp'
11
12VERSION = 1.0
13
14class ArgvParser
15  #
16  # Return a structure describing the options.
17  #
18  def self.parse(args)
19    # The options specified on the command line will be collected in *options*.
20    # We set default values here.
21    options = OpenStruct.new
22    options.results_dir = '.'
23    options.root_path = '.'
24    options.out_file = 'results.xml'
25
26    opts = OptionParser.new do |o|
27      o.banner = 'Usage: unity_to_junit.rb [options]'
28
29      o.separator ''
30      o.separator 'Specific options:'
31
32      o.on('-r', '--results <dir>', 'Look for Unity Results files here.') do |results|
33        # puts "results #{results}"
34        options.results_dir = results
35      end
36
37      o.on('-p', '--root_path <path>', 'Prepend this path to files in results.') do |root_path|
38        options.root_path = root_path
39      end
40
41      o.on('-o', '--output <filename>', 'XML file to generate.') do |out_file|
42        # puts "out_file: #{out_file}"
43        options.out_file = out_file
44      end
45
46      o.separator ''
47      o.separator 'Common options:'
48
49      # No argument, shows at tail.  This will print an options summary.
50      o.on_tail('-h', '--help', 'Show this message') do
51        puts o
52        exit
53      end
54
55      # Another typical switch to print the version.
56      o.on_tail('--version', 'Show version') do
57        puts "unity_to_junit.rb version #{VERSION}"
58        exit
59      end
60    end
61
62    opts.parse!(args)
63    options
64  end # parse()
65end # class OptparseExample
66
67class UnityToJUnit
68  include FileUtils::Verbose
69  attr_reader :report, :total_tests, :failures, :ignored
70  attr_writer :targets, :root, :out_file
71
72  def initialize
73    @report = ''
74    @unit_name = ''
75  end
76
77  def run
78    # Clean up result file names
79    results = @targets.map { |target| target.tr('\\', '/') }
80    # puts "Output File: #{@out_file}"
81    f = File.new(@out_file, 'w')
82    write_xml_header(f)
83    write_suites_header(f)
84    results.each do |result_file|
85      lines = File.readlines(result_file).map(&:chomp)
86
87      raise "Empty test result file: #{result_file}" if lines.empty?
88
89      result_output = get_details(result_file, lines)
90      tests, failures, ignored = parse_test_summary(lines)
91      result_output[:counts][:total] = tests
92      result_output[:counts][:failed] = failures
93      result_output[:counts][:ignored] = ignored
94      result_output[:counts][:passed] = (result_output[:counts][:total] - result_output[:counts][:failed] - result_output[:counts][:ignored])
95
96      # use line[0] from the test output to get the test_file path and name
97      test_file_str = lines[0].tr('\\', '/')
98      test_file_str = test_file_str.split(':')
99      test_file = if test_file_str.length < 2
100                    result_file
101                  else
102                    test_file_str[0] + ':' + test_file_str[1]
103                  end
104      result_output[:source][:path] = File.dirname(test_file)
105      result_output[:source][:file] = File.basename(test_file)
106
107      # save result_output
108      @unit_name = File.basename(test_file, '.*')
109
110      write_suite_header(result_output[:counts], f)
111      write_failures(result_output, f)
112      write_tests(result_output, f)
113      write_ignored(result_output, f)
114      write_suite_footer(f)
115    end
116    write_suites_footer(f)
117    f.close
118  end
119
120  def usage(err_msg = nil)
121    puts "\nERROR: "
122    puts err_msg if err_msg
123    puts 'Usage: unity_to_junit.rb [options]'
124    puts ''
125    puts 'Specific options:'
126    puts '    -r, --results <dir>              Look for Unity Results files here.'
127    puts '    -p, --root_path <path>           Prepend this path to files in results.'
128    puts '    -o, --output <filename>          XML file to generate.'
129    puts ''
130    puts 'Common options:'
131    puts '    -h, --help                       Show this message'
132    puts '        --version                    Show version'
133
134    exit 1
135  end
136
137  protected
138
139  def get_details(_result_file, lines)
140    results = results_structure
141    lines.each do |line|
142      line = line.tr('\\', '/')
143      _src_file, src_line, test_name, status, msg = line.split(/:/)
144      case status
145      when 'IGNORE' then results[:ignores] << { test: test_name, line: src_line, message: msg }
146      when 'FAIL'   then results[:failures] << { test: test_name, line: src_line, message: msg }
147      when 'PASS'   then results[:successes] << { test: test_name, line: src_line, message: msg }
148      end
149    end
150    results
151  end
152
153  def parse_test_summary(summary)
154    raise "Couldn't parse test results: #{summary}" unless summary.find { |v| v =~ /(\d+) Tests (\d+) Failures (\d+) Ignored/ }
155    [Regexp.last_match(1).to_i, Regexp.last_match(2).to_i, Regexp.last_match(3).to_i]
156  end
157
158  def here
159    File.expand_path(File.dirname(__FILE__))
160  end
161
162  private
163
164  def results_structure
165    {
166      source: { path: '', file: '' },
167      successes: [],
168      failures: [],
169      ignores: [],
170      counts: { total: 0, passed: 0, failed: 0, ignored: 0 },
171      stdout: []
172    }
173  end
174
175  def write_xml_header(stream)
176    stream.puts "<?xml version='1.0' encoding='utf-8' ?>"
177  end
178
179  def write_suites_header(stream)
180    stream.puts '<testsuites>'
181  end
182
183  def write_suite_header(counts, stream)
184    stream.puts "\t<testsuite errors=\"0\" skipped=\"#{counts[:ignored]}\" failures=\"#{counts[:failed]}\" tests=\"#{counts[:total]}\" name=\"unity\">"
185  end
186
187  def write_failures(results, stream)
188    result = results[:failures]
189    result.each do |item|
190      filename = File.join(results[:source][:path], File.basename(results[:source][:file], '.*'))
191      stream.puts "\t\t<testcase classname=\"#{@unit_name}\" name=\"#{item[:test]}\" time=\"0\">"
192      stream.puts "\t\t\t<failure message=\"#{item[:message]}\" type=\"Assertion\"/>"
193      stream.puts "\t\t\t<system-err>&#xD;[File] #{filename}&#xD;[Line] #{item[:line]}&#xD;</system-err>"
194      stream.puts "\t\t</testcase>"
195    end
196  end
197
198  def write_tests(results, stream)
199    result = results[:successes]
200    result.each do |item|
201      stream.puts "\t\t<testcase classname=\"#{@unit_name}\" name=\"#{item[:test]}\" time=\"0\" />"
202    end
203  end
204
205  def write_ignored(results, stream)
206    result = results[:ignores]
207    result.each do |item|
208      filename = File.join(results[:source][:path], File.basename(results[:source][:file], '.*'))
209      puts "Writing ignored tests for test harness: #{filename}"
210      stream.puts "\t\t<testcase classname=\"#{@unit_name}\" name=\"#{item[:test]}\" time=\"0\">"
211      stream.puts "\t\t\t<skipped message=\"#{item[:message]}\" type=\"Assertion\"/>"
212      stream.puts "\t\t\t<system-err>&#xD;[File] #{filename}&#xD;[Line] #{item[:line]}&#xD;</system-err>"
213      stream.puts "\t\t</testcase>"
214    end
215  end
216
217  def write_suite_footer(stream)
218    stream.puts "\t</testsuite>"
219  end
220
221  def write_suites_footer(stream)
222    stream.puts '</testsuites>'
223  end
224end # UnityToJUnit
225
226if __FILE__ == $0
227  # parse out the command options
228  options = ArgvParser.parse(ARGV)
229
230  # create an instance to work with
231  utj = UnityToJUnit.new
232  begin
233    # look in the specified or current directory for result files
234    targets = "#{options.results_dir.tr('\\', '/')}**/*.test*"
235
236    results = Dir[targets]
237    raise "No *.testpass, *.testfail, or *.testresults files found in '#{targets}'" if results.empty?
238    utj.targets = results
239
240    # set the root path
241    utj.root = options.root_path
242
243    # set the output XML file name
244    # puts "Output File from options: #{options.out_file}"
245    utj.out_file = options.out_file
246
247    # run the summarizer
248    puts utj.run
249  rescue StandardError => e
250    utj.usage e.message
251  end
252end
253