Repository: parroty/excoveralls
Pull request #218 (2020-03-16 10:29:39)

Add support to generate XML files
This PR is to add the command `mix coveralls.xml`
The generated file can then be used in quality software like SonarQube (https://www.sonarqube.org/)


Patch:
diff --git a/README.md b/README.md
index 2186242..939750e 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ end
     - [[mix coveralls.detail] Show coverage with detail](#mix-coverallsdetail-show-coverage-with-detail)
     - [[mix coveralls.html] Show coverage as HTML report](#mix-coverallshtml-show-coverage-as-html-report)
     - [[mix coveralls.json] Show coverage as JSON report](#mix-coverallsjson-show-coverage-as-json-report)
+    - [[mix coveralls.xml] Show coverage as XML report](#mix-coverallsjson-show-coverage-as-xml-report)
   - [coveralls.json](#coverallsjson)
       - [Stop Words](#stop-words)
       - [Exclude Files](#exclude-files)
@@ -321,6 +322,13 @@ or to Code Climate using their [test-reporter](https://docs.codeclimate.com/docs
 
 Output reports are written to `cover/excoveralls.json` by default, however, the path can be specified by overwriting the `"output_dir"` coverage option.
 
+### [mix coveralls.xml] Show coverage as XML report
+This task displays coverage information at the source-code level formatted as a XML document.
+The report follows a format supported by several code coverage services like SonarQube.
+Output to the shell is the same as running the command `mix coveralls` (to suppress this output, add `"print_summary": false` to your project's `coveralls.json` file). In a similar manner to `mix coveralls.detail`, reported source code can be filtered by specifying arguments using the `--filter` flag.
+
+Output reports are written to `cover/excoveralls.xml` by default, however, the path can be specified by overwriting the `"output_dir"` coverage option.
+
 ## coveralls.json
 `coveralls.json` provides settings for excoveralls.
 
@@ -394,7 +402,8 @@ to `false`:
     "treat_no_relevant_lines_as_covered": true,
     "output_dir": "cover/",
     "template_path": "custom/path/to/template/",
-    "minimum_coverage": 90
+    "minimum_coverage": 90,
+    "xml_base_dir": "custom/path/for/xml/reports/"
   }
 }
 ```
diff --git a/lib/excoveralls.ex b/lib/excoveralls.ex
index 0c84667..b625ad4 100644
--- a/lib/excoveralls.ex
+++ b/lib/excoveralls.ex
@@ -16,6 +16,7 @@ defmodule ExCoveralls do
   alias ExCoveralls.Html
   alias ExCoveralls.Json
   alias ExCoveralls.Post
+  alias ExCoveralls.Xml
 
   @type_travis      "travis"
   @type_github      "github"
@@ -26,6 +27,7 @@ defmodule ExCoveralls do
   @type_html        "html"
   @type_json        "json"
   @type_post        "post"
+  @type_xml         "xml"
 
   @doc """
   This method will be called from mix to trigger coverage analysis.
@@ -110,6 +112,13 @@ defmodule ExCoveralls do
     Json.execute(stats, options)
   end
 
+  @doc """
+  Logic for XML output, without posting server
+  """
+  def analyze(stats, @type_xml, options) do
+    Xml.execute(stats, options)
+  end
+
   @doc """
   Logic for posting from general CI server with token.
   """
diff --git a/lib/excoveralls/settings.ex b/lib/excoveralls/settings.ex
index 01874da..c30f26e 100644
--- a/lib/excoveralls/settings.ex
+++ b/lib/excoveralls/settings.ex
@@ -76,6 +76,13 @@ defmodule ExCoveralls.Settings do
     end
   end
 
+  @doc """
+  Get xml base dir
+  """
+  def get_xml_base_dir do
+    Map.get(get_coverage_options(), "xml_base_dir", "")
+  end
+
   @doc """
   Get skip files from the json file.
   """
diff --git a/lib/excoveralls/xml.ex b/lib/excoveralls/xml.ex
new file mode 100644
index 0000000..b4bdded
--- /dev/null
+++ b/lib/excoveralls/xml.ex
@@ -0,0 +1,56 @@
+defmodule ExCoveralls.Xml do
+  @moduledoc """
+  Generate XML output for results.
+  """
+
+  alias ExCoveralls.Settings
+
+  @file_name "excoveralls.xml"
+
+  @doc """
+  Provides an entry point for the module.
+  """
+  def execute(stats, options \\ []) do
+    stats
+    |> generate_xml(Enum.into(options, %{})) 
+    |> write_file(options[:output_dir])
+
+    ExCoveralls.Local.print_summary(stats)
+  end
+
+  def generate_xml(stats, _options) do
+    base_dir = Settings.get_xml_base_dir()
+    "<coverage version=\"1\">" <> Enum.map_join(stats, fn %{name: name, coverage: coverage} ->
+      path = String.replace("#{base_dir}/#{name}", ~r/(\/)+/, "/", global: true)
+      "<file path=\"#{path}\">" <>
+        Enum.map_join(Enum.with_index(coverage), fn 
+          {nil, _line} -> ""
+          {count, line} ->
+            "<lineToCover lineNumber=\"#{line + 1}\" covered=\"#{count != 0}\"/>"
+        end)
+      <> "</file>"      
+    end) <> "</coverage>"
+  end
+
+  defp output_dir(output_dir) do
+    cond do
+      output_dir ->
+        output_dir
+      true ->
+        options = Settings.get_coverage_options
+        case Map.fetch(options, "output_dir") do
+          {:ok, val} -> val
+          _ -> "cover/"
+        end
+    end
+  end
+
+  defp write_file(content, output_dir) do
+    file_path = output_dir(output_dir)
+    unless File.exists?(file_path) do
+      File.mkdir_p!(file_path)
+    end
+    File.write!(Path.expand(@file_name, file_path), content)
+  end
+
+end
diff --git a/lib/mix/tasks.ex b/lib/mix/tasks.ex
index bbdadc1..22ecf5f 100644
--- a/lib/mix/tasks.ex
+++ b/lib/mix/tasks.ex
@@ -119,6 +119,21 @@ defmodule Mix.Tasks.Coveralls do
     end
   end
 
+  defmodule Xml do
+    @moduledoc """
+    Provides an entry point for outputting coveralls information
+    as a XML file.
+    """
+    use Mix.Task
+
+    @shortdoc "Output the test coverage as a XML file"
+    @preferred_cli_env :test
+
+    def run(args) do
+      Mix.Tasks.Coveralls.do_run(args, [ type: "xml" ])
+    end
+  end
+
   defmodule Json do
     @moduledoc """
     Provides an entry point for outputting coveralls information


Test patch:
diff --git a/test/xml_test.exs b/test/xml_test.exs
new file mode 100644
index 0000000..707ac89
--- /dev/null
+++ b/test/xml_test.exs
@@ -0,0 +1,65 @@
+defmodule ExCoveralls.XmlTest do
+  use ExUnit.Case
+  import Mock
+  import ExUnit.CaptureIO
+  alias ExCoveralls.Xml
+
+  @file_name "excoveralls.xml"
+  @test_output_dir "cover_test/"
+
+  @content     "defmodule Test do\n  def test do\n  end\nend\n"
+  @counts      [0, 1, nil, nil]
+  @source_info [%{name: "test/fixtures/test.ex",
+                  source: @content,
+                  coverage: @counts
+               }]
+
+  @stats_result "" <>
+    "----------------\n" <>
+    "COV    FILE                                        LINES RELEVANT   MISSED\n" <>
+    " 50.0% test/fixtures/test.ex                           4        2        1\n"  <>
+    "[TOTAL]  50.0%\n" <>
+    "----------------\n"
+
+  setup do
+    path = Path.expand(@file_name, @test_output_dir)
+
+    # Assert does not exist prior to write
+    assert(File.exists?(path) == false)
+    on_exit fn ->
+      if File.exists?(path) do
+        # Ensure removed after test
+        File.rm!(path)
+        File.rmdir!(@test_output_dir)
+      end
+    end
+
+    {:ok, report: path}
+  end
+
+  test_with_mock "generate xml file", %{report: report}, ExCoveralls.Settings, [],
+      [
+        get_coverage_options: fn -> %{"output_dir" => @test_output_dir} end,
+        get_file_col_width: fn -> 40 end,
+        get_print_summary: fn -> true end,
+        get_print_files: fn -> true end,
+        get_xml_base_dir: fn -> "base_dir" end
+      ] do
+
+    assert capture_io(fn ->
+      Xml.execute(@source_info)
+    end) =~ @stats_result
+
+    assert File.read!(report) =~ ~s(<coverage version="1"><file path="base_dir/test/fixtures/test.ex"><lineToCover lineNumber="1" covered="false"/><lineToCover lineNumber="2" covered="true"/></file></coverage>)
+    assert %{size: 173} = File.stat! report
+  end
+
+  test "generate json file with output_dir parameter", %{report: report} do
+    assert capture_io(fn ->
+      Xml.execute(@source_info, [output_dir: @test_output_dir])
+    end) =~ @stats_result
+
+    assert File.read!(report) =~ ~s(<coverage version="1"><file path="/test/fixtures/test.ex"><lineToCover lineNumber="1" covered="false"/><lineToCover lineNumber="2" covered="true"/></file></coverage>)
+    assert %{size: 165} = File.stat! report
+  end
+end