diff --git a/.buildkite/scripts/merge_xml.sh b/.buildkite/scripts/merge_xml.sh
deleted file mode 100755
index 01d910d43946..000000000000
--- a/.buildkite/scripts/merge_xml.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/bin/bash
-
-set -euo pipefail
-
-# Script to merge all the coverage XML files into just one file.
-# It supports XML files using generic test coverage report format:
-# https://docs.sonarsource.com/sonarqube/9.8/analyzing-source-code/test-coverage/generic-test-data/#generic-test-coverage
-
-sourceFolder="build/test-coverage"
-mergedCoverageFileName="coverage_merged.xml"
-
-pushd "${sourceFolder}" > /dev/null
-echo "Generating ${mergedCoverageFileName} into ${sourceFolder}..."
-echo '' > "${mergedCoverageFileName}"
-echo '' >> "${mergedCoverageFileName}"
-
-for file in coverage-*.xml; do
- if [[ "$file" == "${mergedCoverageFileName}" ]]; then
- continue
- fi
- echo " - Adding ${file}"
- sed '1d;$d' "$file" | awk '//' >> "${mergedCoverageFileName}"
-done
-
-echo '' >> "${mergedCoverageFileName}"
-echo 'Done'
-
-popd > /dev/null
-
diff --git a/.buildkite/scripts/run_sonar_scanner.sh b/.buildkite/scripts/run_sonar_scanner.sh
index 55650d26ee7a..e64a966c8c17 100755
--- a/.buildkite/scripts/run_sonar_scanner.sh
+++ b/.buildkite/scripts/run_sonar_scanner.sh
@@ -1,6 +1,12 @@
#!/bin/bash
+
+source .buildkite/scripts/common.sh
+
set -euo pipefail
+add_bin_path
+with_mage
+
run_sonar_scanner() {
local message=""
echo "--- Download coverage reports and merge them"
@@ -15,7 +21,7 @@ run_sonar_scanner() {
fi
echo "Merge all coverage reports"
- .buildkite/scripts/merge_xml.sh
+ mage mergeCoverage
echo "--- Execute sonar scanner CLI"
/scan-source-code.sh
diff --git a/dev/coverage/coverage.go b/dev/coverage/coverage.go
new file mode 100644
index 000000000000..b9b5eab394ed
--- /dev/null
+++ b/dev/coverage/coverage.go
@@ -0,0 +1,143 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+// File partially copied from elastic-package.
+
+package coverage
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "os"
+)
+
+// GenericCoverage is the root element for a Cobertura XML report.
+type GenericCoverage struct {
+ XMLName xml.Name `xml:"coverage"`
+ Version int64 `xml:"version,attr"`
+ Files []*GenericFile `xml:"file"`
+ Timestamp int64 `xml:"-"`
+ TestType string `xml:",comment"`
+}
+
+type GenericFile struct {
+ Path string `xml:"path,attr"`
+ Lines []*GenericLine `xml:"lineToCover"`
+}
+
+type GenericLine struct {
+ LineNumber int64 `xml:"lineNumber,attr"`
+ Covered bool `xml:"covered,attr"`
+}
+
+func (c *GenericCoverage) Bytes() ([]byte, error) {
+ out, err := xml.MarshalIndent(&c, "", " ")
+ if err != nil {
+ return nil, fmt.Errorf("unable to format test results as Coverage: %w", err)
+ }
+
+ var buffer bytes.Buffer
+ buffer.WriteString(xml.Header)
+ buffer.WriteString("\n")
+ buffer.Write(out)
+ return buffer.Bytes(), nil
+}
+
+func (c *GenericFile) merge(b *GenericFile) error {
+ // Merge files
+ for _, coverageLine := range b.Lines {
+ found := false
+ foundId := 0
+ for idx, existingLine := range c.Lines {
+ if existingLine.LineNumber == coverageLine.LineNumber {
+ found = true
+ foundId = idx
+ break
+ }
+ }
+ if !found {
+ c.Lines = append(c.Lines, coverageLine)
+ } else {
+ c.Lines[foundId].Covered = c.Lines[foundId].Covered || coverageLine.Covered
+ }
+ }
+ return nil
+}
+
+// merge merges two coverage reports.
+func (c *GenericCoverage) Merge(other *GenericCoverage) error {
+ // Merge files
+ for _, coverageFile := range other.Files {
+ var target *GenericFile
+ for _, existingFile := range c.Files {
+ if existingFile.Path == coverageFile.Path {
+ target = existingFile
+ break
+ }
+ }
+ if target != nil {
+ if err := target.merge(coverageFile); err != nil {
+ return err
+ }
+ } else {
+ c.Files = append(c.Files, coverageFile)
+ }
+ }
+ return nil
+}
+
+func ReadGenericCoverage(path string) (*GenericCoverage, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open failed: %w", err)
+ }
+ defer f.Close()
+
+ dec := xml.NewDecoder(f)
+
+ var coverage GenericCoverage
+ err = dec.Decode(&coverage)
+ if err != nil {
+ return nil, fmt.Errorf("xml decode failed: %w", err)
+ }
+
+ return &coverage, nil
+}
+
+func MergeGenericCoverageFiles(paths []string, output string) error {
+ f, err := os.Create(output)
+ if err != nil {
+ return fmt.Errorf("cannot open file %s to write merged coverage: %w", output, err)
+ }
+ defer f.Close()
+
+ var coverage *GenericCoverage
+ for _, path := range paths {
+ c, err := ReadGenericCoverage(path)
+ if err != nil {
+ return fmt.Errorf("failed to read coverage from %s: %w", path, err)
+ }
+ if coverage == nil {
+ coverage = c
+ continue
+ }
+ err = coverage.Merge(c)
+ if err != nil {
+ return fmt.Errorf("failed to merge coverage from %s: %w", path, err)
+ }
+ }
+
+ d, err := coverage.Bytes()
+ if err != nil {
+ return fmt.Errorf("failed to encode merged coverage: %w", err)
+ }
+
+ _, err = f.Write(d)
+ if err != nil {
+ return fmt.Errorf("cannot write merged coverage to %s: %w", output, err)
+ }
+
+ return nil
+}
diff --git a/dev/coverage/coverage_test.go b/dev/coverage/coverage_test.go
new file mode 100644
index 000000000000..4f25c60c4db5
--- /dev/null
+++ b/dev/coverage/coverage_test.go
@@ -0,0 +1,31 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package coverage
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMergeCoverage(t *testing.T) {
+ coverageFiles, err := filepath.Glob("testdata/test-coverage-*.xml")
+ require.NoError(t, err)
+
+ expectedCoverage, err := ReadGenericCoverage("testdata/expected-test-coverage.xml")
+ require.NoError(t, err)
+
+ output := filepath.Join(t.TempDir(), "coverage-merged.xml")
+
+ err = MergeGenericCoverageFiles(coverageFiles, output)
+ require.NoError(t, err)
+
+ mergedCoverage, err := ReadGenericCoverage(output)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, expectedCoverage, mergedCoverage)
+}
diff --git a/dev/coverage/testdata/expected-test-coverage.xml b/dev/coverage/testdata/expected-test-coverage.xml
new file mode 100644
index 000000000000..4335bd4f67c8
--- /dev/null
+++ b/dev/coverage/testdata/expected-test-coverage.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/coverage/testdata/test-coverage-1.xml b/dev/coverage/testdata/test-coverage-1.xml
new file mode 100644
index 000000000000..4275ab18537f
--- /dev/null
+++ b/dev/coverage/testdata/test-coverage-1.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/coverage/testdata/test-coverage-2.xml b/dev/coverage/testdata/test-coverage-2.xml
new file mode 100644
index 000000000000..4d5f1888407c
--- /dev/null
+++ b/dev/coverage/testdata/test-coverage-2.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/dev/coverage/testdata/test-coverage-3.xml b/dev/coverage/testdata/test-coverage-3.xml
new file mode 100644
index 000000000000..3e1e45f0b180
--- /dev/null
+++ b/dev/coverage/testdata/test-coverage-3.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/magefile.go b/magefile.go
index 054a67c5acc1..7f3177e806a2 100644
--- a/magefile.go
+++ b/magefile.go
@@ -7,6 +7,7 @@
package main
import (
+ "fmt"
"io"
"os"
"path/filepath"
@@ -16,6 +17,7 @@ import (
"github.com/pkg/errors"
"github.com/elastic/integrations/dev/codeowners"
+ "github.com/elastic/integrations/dev/coverage"
)
var (
@@ -51,6 +53,14 @@ func ImportBeats() error {
return sh.Run("go", args...)
}
+func MergeCoverage() error {
+ coverageFiles, err := filepath.Glob("build/test-coverage/coverage-*.xml")
+ if err != nil {
+ return fmt.Errorf("glob failed: %w", err)
+ }
+ return coverage.MergeGenericCoverageFiles(coverageFiles, "build/test-coverage/coverage_merged.xml")
+}
+
func build() error {
mg.Deps(buildImportBeats)
return nil