Skip to content

Add feature to generate JUnit XML output #720

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ PATH
erubi (>= 1.10.0)
prism (>= 0.28.0)
rbi (>= 0.3.1)
rexml (>= 3.2.6)
sorbet-static-and-runtime (>= 0.5.10187)
thor (>= 0.19.2)

Expand Down Expand Up @@ -57,6 +58,7 @@ GEM
regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
rexml (3.4.1)
rubocop (1.75.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
Expand Down
15 changes: 15 additions & 0 deletions lib/spoom/cli/srb/tc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Tc < Thor
option :format, type: :string, aliases: :f, desc: "Format line output"
option :uniq, type: :boolean, aliases: :u, desc: "Remove duplicated lines"
option :count, type: :boolean, default: true, desc: "Show errors count"
option :junit_output_path, type: :string, desc: "Output failures to XML file formatted for JUnit"
option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
option :sorbet_options, type: :string, default: "", desc: "Pass options to Sorbet"
def tc(*paths_to_select)
Expand All @@ -32,6 +33,7 @@ def tc(*paths_to_select)
uniq = options[:uniq]
format = options[:format]
count = options[:count]
junit_output_path = options[:junit_output_path]
sorbet = options[:sorbet]

unless limit || code || sort
Expand All @@ -55,6 +57,12 @@ def tc(*paths_to_select)

if result.status
say_error(result.err, status: nil, nl: false)
if junit_output_path
doc = Spoom::Sorbet::Errors.to_junit_xml([])
file = File.open(junit_output_path, "w")
doc.write(output: file, indent: 2)
file.close
end
exit(0)
end

Expand Down Expand Up @@ -94,6 +102,13 @@ def tc(*paths_to_select)
say_error(line, status: nil)
end

if junit_output_path
doc = Spoom::Sorbet::Errors.to_junit_xml(errors)
file = File.open(junit_output_path, "w")
doc.write(output: file, indent: 2)
file.close
end

if count
if errors_count == errors.size
say_error("Errors: #{errors_count}", status: nil)
Expand Down
60 changes: 60 additions & 0 deletions lib/spoom/sorbet/errors.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# typed: strict
# frozen_string_literal: true

require "rexml/document"

module Spoom
module Sorbet
module Errors
Expand All @@ -11,6 +13,35 @@ class << self
def sort_errors_by_code(errors)
errors.sort_by { |e| [e.code, e.file, e.line, e.message] }
end

#: (Array[Error]) -> REXML::Document
def to_junit_xml(errors)
testsuite_element = REXML::Element.new("testsuite")
testsuite_element.add_attributes(
"name" => "Sorbet",
"failures" => errors.size,
)

if errors.empty?
# Avoid creating an empty report when there are no errors so that
# reporting tools know that the type checking ran successfully.
testcase_element = testsuite_element.add_element("testcase")
testcase_element.add_attributes(
"name" => "Typecheck",
"tests" => 1,
)
else
errors.each do |error|
testsuite_element.add_element(error.to_junit_xml_element)
end
end

doc = REXML::Document.new
doc << REXML::XMLDecl.new
doc.add_element(testsuite_element)

doc
end
end
# Parse errors from Sorbet output
class Parser
Expand Down Expand Up @@ -153,6 +184,35 @@ def <=>(other)
def to_s
"#{file}:#{line}: #{message} (#{code})"
end

#: -> REXML::Element
def to_junit_xml_element
testcase_element = REXML::Element.new("testcase")
# Unlike traditional test suites, we can't report all tests
# regardless of outcome; we only have errors to report. As a
# result we reinterpret the definitions of the test properties
# bit: the error message becomes the test name and the full error
# info gets plugged into the failure body along with file/line
# information (displayed in Jenkins as the "Stacktrace" for the
# error).
testcase_element.add_attributes(
"name" => message,
"file" => file,
"line" => line,
)
failure_element = testcase_element.add_element("failure")
failure_element.add_attributes(
"type" => code,
)
explanation_text = [
"In file #{file}:\n",
*more,
].join.chomp
# Use CDATA so that parsers know the whitespace is significant.
failure_element.add(REXML::CData.new(explanation_text))

testcase_element
end
end
end
end
Expand Down
1 change: 1 addition & 0 deletions spoom.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Gem::Specification.new do |spec|
spec.add_dependency("erubi", ">= 1.10.0")
spec.add_dependency("prism", ">= 0.28.0")
spec.add_dependency("rbi", ">= 0.3.1")
spec.add_dependency("rexml", ">= 3.2.6")
spec.add_dependency("sorbet-static-and-runtime", ">= 0.5.10187")
spec.add_dependency("thor", ">= 0.19.2")

Expand Down
116 changes: 116 additions & 0 deletions test/spoom/cli/srb/tc_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,122 @@ def test_display_errors_with_path_option
project.destroy!
end

def test_output_no_errors_to_junit_xml
@project.write_sorbet_config!("file.rb")
result = @project.spoom("srb tc --junit_output_path=junit.xml")
expected_doc = <<~XML.chomp
<?xml version='1.0'?>
<testsuite name='Sorbet' failures='0'>
<testcase name='Typecheck' tests='1'/>
</testsuite>
XML
assert_equal(expected_doc, @project.read("junit.xml"))
assert(result.status)
end

def test_output_errors_to_junit_xml
result = @project.spoom("srb tc --junit_output_path=junit.xml")
expected_doc = <<~XML.chomp
<?xml version='1.0'?>
<testsuite name='Sorbet' failures='7'>
<testcase name='Unable to resolve constant `Bar`' file='errors/errors.rb' line='5'>
<failure type='5002'>
<![CDATA[In file errors/errors.rb:
5 | sig { params(bar: Bar).returns(C) }
^^^
Did you mean `Dir`? Use `-a` to autocorrect
errors/errors.rb:5: Replace with `Dir`
5 | sig { params(bar: Bar).returns(C) }
^^^
<GIT_LINK>/rbi/core/dir.rbi#L11: `Dir` defined here
11 |class Dir < Object
^^^^^^^^^^^^^^^^^^]]>
</failure>
</testcase>
<testcase name='Unable to resolve constant `C`' file='errors/errors.rb' line='5'>
<failure type='5002'>
<![CDATA[In file errors/errors.rb:
5 | sig { params(bar: Bar).returns(C) }
^
Did you mean `T`? Use `-a` to autocorrect
errors/errors.rb:5: Replace with `T`
5 | sig { params(bar: Bar).returns(C) }
^
<GIT_LINK>/rbi/sorbet/t.rbi#L16: `T` defined here
16 |module T
^^^^^^^^
Did you mean `GC`? Use `-a` to autocorrect
errors/errors.rb:5: Replace with `GC`
5 | sig { params(bar: Bar).returns(C) }
^
<GIT_LINK>/rbi/core/gc.rbi#L12: `GC` defined here
12 |module GC
^^^^^^^^^]]>
</failure>
</testcase>
<testcase name='Method `params` does not exist on `T.class_of(Foo)`' file='errors/errors.rb' line='5'>
<failure type='7003'>
<![CDATA[In file errors/errors.rb:
5 | sig { params(bar: Bar).returns(C) }
^^^^^^]]>
</failure>
</testcase>
<testcase name='Method `sig` does not exist on `T.class_of(Foo)`' file='errors/errors.rb' line='5'>
<failure type='7003'>
<![CDATA[In file errors/errors.rb:
5 | sig { params(bar: Bar).returns(C) }
^^^
Autocorrect: Use `-a` to autocorrect
errors/errors.rb:5: Insert `extend T::Sig`
5 | sig { params(bar: Bar).returns(C) }
^]]>
</failure>
</testcase>
<testcase name='Wrong number of arguments for constructor. Expected: `0`, got: `1`' file='errors/errors.rb' line='10'>
<failure type='7004'>
<![CDATA[In file errors/errors.rb:
10 |b = Foo.new(42)
^^
<GIT_LINK>/rbi/core/basic_object.rbi#L228: `initialize` defined here
228 | def initialize(); end
^^^^^^^^^^^^^^^^
Autocorrect: Use `-a` to autocorrect
errors/errors.rb:10: Delete
10 |b = Foo.new(42)
^^]]>
</failure>
</testcase>
<testcase name='Method `c` does not exist on `T.class_of(&lt;root&gt;)`' file='errors/errors.rb' line='11'>
<failure type='7003'>
<![CDATA[In file errors/errors.rb:
11 |b.foo(b, c)
^]]>
</failure>
</testcase>
<testcase name='Too many arguments provided for method `Foo#foo`. Expected: `1`, got: `2`' file='errors/errors.rb' line='11'>
<failure type='7004'>
<![CDATA[In file errors/errors.rb:
11 |b.foo(b, c)
^
errors/errors.rb:6: `foo` defined here
6 | def foo(bar)
^^^^^^^^^^^^
Autocorrect: Use `-a` to autocorrect
errors/errors.rb:11: Delete
11 |b.foo(b, c)
^^^]]>
</failure>
</testcase>
</testsuite>
XML

output_xml = @project.read("junit.xml")
.gsub(%r{^(\s+)https://github\.com/sorbet/sorbet/tree/[0-9a-f]+}, '\1<GIT_LINK>')

assert_equal(expected_doc, output_xml)
refute(result.status)
end

def test_pass_options_to_sorbet
result = @project.spoom("srb tc --no-color --sorbet-options \"--no-config -e 'foo'\"")
assert_equal(<<~MSG, result.err)
Expand Down
Loading